HMAC-signed URLs on Cloudflare Workers

By

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 without a session cookie or OAuth.

I needed this for StackPlan, a deployment stack advisor I built. Paid users get a deploy brief their coding agent can fetch with ?token=…, without a browser login.

Cloudflare Workers run in workerd, not Node. You can’t use crypto.createHmac, so you use the built-in Web Crypto API without 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:

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. It uses 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, which is already required and 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, with 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. Otherwise skip it. The crypto plumbing is the same either way.

Quick checklist

That’s it. A few dozen lines, no npm packages, runs everywhere Workers run.

Want me to talk about your product? You can sponsor this site.

~~~

Related posts about cloudflare: