Article 4

Deploy API Auth Access Tokens with ML-DSA-65 on Cloudflare Workers (Rust)

Published: 2026-07-11

Rust Cloudflare Workers ML-DSA-65

This guide shows a full code snippet for issuing and verifying API access tokens signed with ML-DSA-65 in a Rust Cloudflare Worker. You will get two endpoints: POST /token (issue token) and GET /protected (verify token).

1) Create the Worker project

npm create cloudflare@latest pqc-auth-worker -- --type=rust
cd pqc-auth-worker

2) Cargo.toml (full)

[package]
name = "pqc-auth-worker"
version = "0.1.0"
edition = "2021"

[lib]
crate-type = ["cdylib"]

[dependencies]
worker = "0.4"
serde = { version = "1", features = ["derive"] }
serde_json = "1"
base64 = "0.22"
hex = "0.4"
rand = { version = "0.8", default-features = false, features = ["std", "getrandom"] }
fips204 = "0.4"

3) src/lib.rs (full API auth implementation)

use base64::{engine::general_purpose::URL_SAFE_NO_PAD, Engine as _};
use rand::RngCore;
use serde::{Deserialize, Serialize};
use worker::*;

// ML-DSA-65 types from the fips204 crate.
use fips204::ml_dsa_65::{Signature, SigningKey, VerifyingKey};

#[derive(Debug, Serialize, Deserialize)]
struct AccessClaims {
    sub: String,
    scope: String,
    iat: u64,
    exp: u64,
    nonce: String,
}

#[derive(Debug, Deserialize)]
struct TokenRequest {
    sub: String,
    scope: String,
    ttl_seconds: Option<u64>,
}

#[derive(Debug, Serialize)]
struct TokenResponse {
    access_token: String,
    token_type: String,
    expires_in: u64,
}

fn now_unix_seconds() -> u64 {
    (js_sys::Date::now() as u64) / 1000
}

fn random_nonce() -> String {
    let mut bytes = [0u8; 16];
    rand::thread_rng().fill_bytes(&mut bytes);
    hex::encode(bytes)
}

fn encode_segment(bytes: &[u8]) -> String {
    URL_SAFE_NO_PAD.encode(bytes)
}

fn decode_segment(s: &str) -> Result<Vec<u8>> {
    URL_SAFE_NO_PAD
        .decode(s)
        .map_err(|e| Error::RustError(format!("base64 decode error: {e}")))
}

fn load_signing_key(env: &Env) -> Result<SigningKey> {
    let sk_b64 = env.var("ML_DSA65_SIGNING_KEY_B64")?.to_string();
    let sk_bytes = decode_segment(&sk_b64)?;
    SigningKey::from_bytes(&sk_bytes)
        .map_err(|e| Error::RustError(format!("invalid signing key: {e}")))
}

fn load_verifying_key(env: &Env) -> Result<VerifyingKey> {
    let vk_b64 = env.var("ML_DSA65_VERIFYING_KEY_B64")?.to_string();
    let vk_bytes = decode_segment(&vk_b64)?;
    VerifyingKey::from_bytes(&vk_bytes)
        .map_err(|e| Error::RustError(format!("invalid verifying key: {e}")))
}

fn issue_access_token(signing_key: &SigningKey, claims: &AccessClaims) -> Result<String> {
    let payload = serde_json::to_vec(claims)
        .map_err(|e| Error::RustError(format!("serialize claims error: {e}")))?;

    let sig: Signature = signing_key.sign(&payload);
    let token = format!("{}.{}", encode_segment(&payload), encode_segment(sig.as_bytes()));
    Ok(token)
}

fn verify_access_token(verifying_key: &VerifyingKey, token: &str) -> Result<AccessClaims> {
    let mut parts = token.split('.');
    let payload_b64 = parts
        .next()
        .ok_or_else(|| Error::RustError("missing payload segment".into()))?;
    let sig_b64 = parts
        .next()
        .ok_or_else(|| Error::RustError("missing signature segment".into()))?;
    if parts.next().is_some() {
        return Err(Error::RustError("token has too many segments".into()));
    }

    let payload = decode_segment(payload_b64)?;
    let sig_bytes = decode_segment(sig_b64)?;

    let signature = Signature::from_bytes(&sig_bytes)
        .map_err(|e| Error::RustError(format!("invalid signature format: {e}")))?;

    verifying_key
        .verify(&payload, &signature)
        .map_err(|_| Error::RustError("signature verification failed".into()))?;

    let claims: AccessClaims = serde_json::from_slice(&payload)
        .map_err(|e| Error::RustError(format!("invalid claims JSON: {e}")))?;

    if now_unix_seconds() > claims.exp {
        return Err(Error::RustError("token expired".into()));
    }

    Ok(claims)
}

fn bearer_token_from_request(req: &Request) -> Result<String> {
    let header = req
        .headers()
        .get("Authorization")?
        .ok_or_else(|| Error::RustError("missing Authorization header".into()))?;
    let prefix = "Bearer ";
    if !header.starts_with(prefix) {
        return Err(Error::RustError("Authorization must start with Bearer".into()));
    }
    Ok(header[prefix.len()..].trim().to_string())
}

#[event(fetch)]
pub async fn fetch(mut req: Request, env: Env, _ctx: Context) -> Result<Response> {
    console_error_panic_hook::set_once();

    let router = Router::new();

    router
        .post_async("/token", move |mut req, ctx| async move {
            let body: TokenRequest = req.json().await?;
            let ttl = body.ttl_seconds.unwrap_or(900).clamp(60, 3600);
            let now = now_unix_seconds();

            let claims = AccessClaims {
                sub: body.sub,
                scope: body.scope,
                iat: now,
                exp: now + ttl,
                nonce: random_nonce(),
            };

            let signing_key = load_signing_key(ctx.env())?;
            let access_token = issue_access_token(&signing_key, &claims)?;

            Response::from_json(&TokenResponse {
                access_token,
                token_type: "Bearer".to_string(),
                expires_in: ttl,
            })
        })
        .get_async("/protected", move |req, ctx| async move {
            let token = bearer_token_from_request(&req)?;
            let verifying_key = load_verifying_key(ctx.env())?;
            let claims = verify_access_token(&verifying_key, &token)?;

            Response::from_json(&serde_json::json!({
                "ok": true,
                "message": "Access granted",
                "subject": claims.sub,
                "scope": claims.scope,
                "exp": claims.exp
            }))
        })
        .run(req, env)
        .await
}

4) wrangler.toml

name = "pqc-auth-worker"
main = "build/worker/shim.mjs"
compatibility_date = "2026-07-01"

[build]
command = "cargo install -q worker-build && worker-build --release"

5) Generate and upload keys

Generate an ML-DSA-65 keypair locally (with your preferred secure tooling), then store keys as base64url Worker secrets.

npx wrangler secret put ML_DSA65_SIGNING_KEY_B64
npx wrangler secret put ML_DSA65_VERIFYING_KEY_B64

6) Deploy

npx wrangler deploy

7) Test token issue + protected API

# Issue token
curl -s -X POST "https://<your-worker>.workers.dev/token" \
  -H "content-type: application/json" \
  -d '{"sub":"user-123","scope":"read:reports","ttl_seconds":900}'

# Call protected endpoint
curl -s "https://<your-worker>.workers.dev/protected" \
  -H "Authorization: Bearer <access_token>"

Production notes

Rotate signing keys with overlap windows, keep token TTL short, include issuer/audience claims, and add replay protection backed by KV or Durable Objects for high-risk APIs.

Back to all articles