rust 70 lines · 9 steps

Signed download links as an Axum extractor

An HMAC-signed, time-limited URL scheme enforced automatically through a custom Axum extractor.

Explained by highlit
1use axum::{
2 extract::{FromRequestParts, Path, Query},
3 http::{request::Parts, StatusCode},
4 response::{IntoResponse, Redirect},
5};
6use hmac::{Hmac, Mac};
7use serde::Deserialize;
8use sha2::Sha256;
9use std::time::{SystemTime, UNIX_EPOCH};
10 
11type HmacSha256 = Hmac<Sha256>;
12const SECRET: &[u8] = b"change-me-in-config";
13 
14fn sign(object: &str, expires: u64) -> String {
15 let mut mac = HmacSha256::new_from_slice(SECRET).expect("valid key length");
16 mac.update(object.as_bytes());
17 mac.update(&expires.to_be_bytes());
18 hex::encode(mac.finalize().into_bytes())
19}
20 
21pub async fn create_download_url(Path(object): Path<String>) -> impl IntoResponse {
22 let expires = SystemTime::now()
23 .duration_since(UNIX_EPOCH)
24 .unwrap()
25 .as_secs()
26 + 300;
27 let sig = sign(&object, expires);
28 let url = format!("/files/{object}?expires={expires}&sig={sig}");
29 (StatusCode::CREATED, url)
30}
31 
32#[derive(Deserialize)]
33struct SignedParams {
34 expires: u64,
35 sig: String,
36}
37 
38pub struct VerifiedDownload(pub String);
39 
40impl<S: Send + Sync> FromRequestParts<S> for VerifiedDownload {
41 type Rejection = (StatusCode, &'static str);
42 
43 async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
44 let Path(object) = Path::<String>::from_request_parts(parts, state)
45 .await
46 .map_err(|_| (StatusCode::BAD_REQUEST, "missing object"))?;
47 let Query(params) = Query::<SignedParams>::from_request_parts(parts, state)
48 .await
49 .map_err(|_| (StatusCode::BAD_REQUEST, "missing signature"))?;
50 
51 let now = SystemTime::now().duration_since(UNIX_EPOCH).unwrap().as_secs();
52 if params.expires < now {
53 return Err((StatusCode::FORBIDDEN, "link expired"));
54 }
55 
56 let mut mac = HmacSha256::new_from_slice(SECRET).unwrap();
57 mac.update(object.as_bytes());
58 mac.update(&params.expires.to_be_bytes());
59 let expected = hex::decode(&params.sig)
60 .map_err(|_| (StatusCode::FORBIDDEN, "malformed signature"))?;
61 mac.verify_slice(&expected)
62 .map_err(|_| (StatusCode::FORBIDDEN, "invalid signature"))?;
63 
64 Ok(VerifiedDownload(object))
65 }
66}
67 
68pub async fn download(VerifiedDownload(object): VerifiedDownload) -> impl IntoResponse {
69 Redirect::temporary(&format!("https://cdn.internal/blobs/{object}"))
70}
01 / 01
STEP 01

Walkthrough

Space play step click any line
Three takeaways
  1. 1HMAC over the object name plus an expiry timestamp lets a server hand out tamper-proof links without storing per-link state.
  2. 2Modeling verification as a FromRequestParts extractor pushes the security check into the type system so handlers only run on valid requests.
  3. 3Always compare signatures with a constant-time verifier and reject expired timestamps before trusting any signed parameter.

Related explainers

Share this explainer

Here's the card — post it anywhere.

Signed download links as an Axum extractor — share card
Made with highlit — turn any snippet into a walkthrough like this in about a minute.
Explain your code