HMAC-signed URLs on Cloudflare Workers
By Flavio Copes
Sign URLs with HMAC on Cloudflare Workers using the Web Crypto API. Grant time-limited access without sessions or cookies, no Node crypto needed.
Sometimes you need to share a link that grants access to something private, but only for a while.
A signed URL is the answer. You embed an ID and an expiry in the link, sign the payload with a secret, and anyone holding a valid token can fetch the resource. No session cookie. No OAuth dance.
I needed this for StackPlan, a deployment stack advisor I built. Paid users get a deploy brief their coding agent can fetch with ?token=… — no browser login required.
Cloudflare Workers run in workerd, not Node. You can’t use crypto.createHmac. You use the Web Crypto API instead. It’s built in. No imports.
To sanity-check an HMAC or SHA hash outside your Worker, I built a free hash generator that computes them in your browser.
What goes in the token
Keep the payload simple. Two fields are enough:
- the resource ID (a report ID, a file name, whatever)
- an expiry timestamp (Unix seconds)
Concatenate them, sign the string, and append the signature to the URL.
Example payload before signing:
brief-v1:rpt_abc123:1735689600
The prefix (brief-v1:) is a version tag. Bump it if you ever change the format. Old tokens die cleanly.
Import the key and sign
HMAC needs a secret key imported into crypto.subtle first:
async function importHmacKey(secret) {
return crypto.subtle.importKey(
'raw',
new TextEncoder().encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false,
['sign', 'verify'],
)
}
Notice ['sign', 'verify'] — you’ll need both.
To sign:
async function signToken(secret, reportId, expiresAt) {
const key = await importHmacKey(secret)
const message = `brief-v1:${reportId}:${expiresAt}`
const signature = await crypto.subtle.sign(
'HMAC',
key,
new TextEncoder().encode(message),
)
return base64UrlEncode(signature)
}
URLs can’t carry raw binary. Encode the signature as base64url — standard base64, but + becomes -, / becomes _, and you drop the padding =:
function base64UrlEncode(buffer) {
const bytes = new Uint8Array(buffer)
let binary = ''
for (const byte of bytes) binary += String.fromCharCode(byte)
return btoa(binary).replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '')
}
The full URL looks like:
/api/reports/rpt_abc123/brief.md?expires=1735689600&token=Ab3xK9...
Verify on the request
When a request arrives, parse reportId, expires, and token from the query string.
Check expiry first. If Date.now() / 1000 > expires, reject. No point verifying a dead token.
Then rebuild the message and verify the signature:
async function verifyToken(secret, reportId, expiresAt, token) {
if (Date.now() / 1000 > expiresAt) return false
const key = await importHmacKey(secret)
const message = `brief-v1:${reportId}:${expiresAt}`
const signature = base64UrlDecode(token)
return crypto.subtle.verify(
'HMAC',
key,
signature,
new TextEncoder().encode(message),
)
}
crypto.subtle.verify compares in constant time. That’s important. A naive string comparison (token === expected) can leak the secret byte by byte through timing attacks.
You’ll need a small base64url decoder for the incoming token:
function base64UrlDecode(str) {
const padded = str + '='.repeat((4 - str.length % 4) % 4)
const binary = atob(padded.replace(/-/g, '+').replace(/_/g, '/'))
const bytes = new Uint8Array(binary.length)
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i)
return bytes.buffer
}
Where to store the secret
Reuse an existing app secret if you have one. On StackPlan I sign with BETTER_AUTH_SECRET — already required, already rotated with auth.
Mint tokens server-side only, after you’ve confirmed the user owns the resource. Never let the client pick its own expiry.
A simpler variant
StackPlan’s production tokens sign only the report ID — no expiry in the payload. Possession of the URL is the entitlement, same as a share link. The HMAC stops anyone from guessing tokens for reports they haven’t paid for.
If you want time limits, add expiresAt to the signed message. If you don’t, skip it. The crypto plumbing is the same either way.
Quick checklist
- Use
crypto.subtle, not Nodecrypto - Sign a versioned string:
prefix:id:expiry - Verify with
crypto.subtle.verify, not=== - Reject expired tokens before doing crypto work
- Mint tokens only after your own auth check passes
That’s it. A few dozen lines, no npm packages, runs everywhere Workers run.
Related posts about cloudflare: