Signing Requests

You can implement the common URL authentication method, request signing in a Workers app using the Web Crypto API.

This example authenticates the path of a URL and accompanying expiration timestamp, using a Hash-based Message Authentication Code (HMAC) and SHA-256 digest algorithm. User agents must provide the correct path, expiration timestamp, and HMAC to successfully fetch an authenticated resource using query parameters. The request fails if any of those three parameters are incorrect. Note that the authenticity of the expiration timestamp is covered by the HMAC, so you can assume that the user-provided timestamp is correct if the HMAC is correct. This also gives you the URL expiration and allows you determine if a URL is expired.

Verifying signed requests

This example verifies the HMAC for any request URL with a pathname starting with /verify/.

For debugging, this code returns error code 403 if the URL or HMAC is invalid or if the URL is expired. You can choose to return a 404 page.

Example Workers script verifying the HMAC and returning a 403 error:

// We'll need some super-secret data to use as a symmetric key.
const encoder = new TextEncoder()
const SECRET_KEY_DATA = encoder.encode('my secret symmetric key')

addEventListener('fetch', event => {
  event.respondWith(verifyAndFetch(event.request))
})

async function verifyAndFetch(request) {
  const url = new URL(request.url)

  // If the path doesn't begin with our protected prefix, just pass the request
  // through.
  if (!url.pathname.startsWith('/verify/')) {
    return fetch(request)
  }

  // Make sure we have the minimum necessary query parameters.
  if (!url.searchParams.has('mac') || !url.searchParams.has('expiry')) {
    return new Response('Missing query parameter', { status: 403 })
  }

  const key = await crypto.subtle.importKey(
    'raw',
    secretKeyData,
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['verify'],
  )

  // Extract the query parameters we need and run the HMAC algorithm on the
  // parts of the request we're authenticating: the path and the expiration
  // timestamp.
  const expiry = Number(url.searchParams.get('expiry'))
  const dataToAuthenticate = url.pathname + expiry

  // The received MAC is Base64-encoded, so we have to go to some trouble to
  // get it into a buffer type that crypto.subtle.verify() can read.
  const receivedMacBase64 = url.searchParams.get('mac')
  const receivedMac = byteStringToUint8Array(atob(receivedMacBase64))

  // Use crypto.subtle.verify() to guard against timing attacks. Since HMACs use
  // symmetric keys, we could implement this by calling crypto.subtle.sign() and
  // then doing a string comparison -- this is insecure, as string comparisons
  // bail out on the first mismatch, which leaks information to potential
  // attackers.
  const verified = await crypto.subtle.verify(
    'HMAC',
    key,
    receivedMac,
    encoder.encode(dataToAuthenticate),
  )

  if (!verified) {
    const body = 'Invalid MAC'
    return new Response(body, { status: 403 })
  }

  if (Date.now() > expiry) {
    const body = `URL expired at ${new Date(expiry)}`
    return new Response(body, { status: 403 })
  }

  // We've verified the MAC and expiration time; we're good to pass the request
  // through.
  return fetch(request)
}

// Convert a ByteString (a string whose code units are all in the range
// [0, 255]), to a Uint8Array. If you pass in a string with code units larger
// than 255, their values will overflow!
function byteStringToUint8Array(byteString) {
  const ui = new Uint8Array(byteString.length)
  for (let i = 0; i < byteString.length; ++i) {
    ui[i] = byteString.charCodeAt(i)
  }
  return ui
}

Generating signed requests

Typically, signed requests are delivered to the user in some out-of-band way, such as email or are generated by the user themselves if they possess the symmetric key. You can also generate signed requests from within a Workers app.

Note: Signed requests expire after one minute. We recommend choosing expiration durations dynamically, depending on the path or a query parameter.

For request URLs beginning with /generate/, we replace /generate/ with /verify/, sign the resulting path with its timestamp, and return the full, signed URL in the response body.

Example Workers script signed request:

addEventListener('fetch', event => {
  const url = new URL(event.request.url)
  const prefix = '/generate/'
  if (url.pathname.startsWith(prefix)) {
    // Replace the "/generate/" path prefix with "/verify/", which we
    // use in the first example to recognize authenticated paths.
    url.pathname = `/verify/${url.pathname.slice(prefix.length)}`
    event.respondWith(generateSignedUrl(url))
  } else {
    event.respondWith(fetch(event.request))
  }
})

async function generateSignedUrl(url) {
  // We'll need some super-secret data to use as a symmetric key.
  const encoder = new TextEncoder()
  const secretKeyData = encoder.encode('my secret symmetric key')
  const key = await crypto.subtle.importKey(
    'raw',
    secretKeyData,
    { name: 'HMAC', hash: 'SHA-256' },
    false,
    ['sign'],
  )

  // Signed requests expire after one minute. Note that you could choose
  // expiration durations dynamically, depending on, e.g. the path or a query
  // parameter.
  const expirationMs = 60000
  const expiry = Date.now() + expirationMs
  const dataToAuthenticate = url.pathname + expiry

  const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(dataToAuthenticate))

  // `mac` is an ArrayBuffer, so we need to jump through a couple of hoops to get
  // it into a ByteString, and then a Base64-encoded string.
  const base64Mac = btoa(String.fromCharCode(...new Uint8Array(mac)))

  url.searchParams.set('mac', base64Mac)
  url.searchParams.set('expiry', expiry)

  return new Response(url)
}