Article 4
Deploy API Auth Access Tokens with ML-DSA-65 on Cloudflare Workers (Rust)
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.