# HMAC-signed URLs on Cloudflare Workers

> Sign URLs with HMAC on Cloudflare Workers using the Web Crypto API. Grant time-limited access without sessions or cookies, no Node crypto needed.

Author: Flavio Copes | Published: 2026-07-11 | Canonical: https://flaviocopes.com/hmac-signed-urls-cloudflare-workers/

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](https://stackplan.dev), 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](https://flaviocopes.com/tools/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:

```js
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:

```js
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 `=`:

```js
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:

```js
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:

```js
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 Node `crypto`
- 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.
