Sign requests
Sign and verify a request using the HMAC and SHA-256 algorithms or return a 403.
// You will need some super-secret data to use as a symmetric key.
const encoder = new TextEncoder();
const secretKeyData = encoder.encode('my secret symmetric key');
// 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;
}
async function verifyAndFetch(request) {
const url = new URL(request.url);
// If the path does not begin with our protected prefix, pass the request through
if (!url.pathname.startsWith('/verify/')) {
return fetch(request);
}
// Make sure you 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 are authenticating: the path and the expiration
// timestamp. It is crucial to pad the input data, for example, by adding a symbol
// in-between the two fields that can never occur on the right side. In this
// case, use the @ symbol to separate the fields.
const expiry = Number(url.searchParams.get('expiry'));
const dataToAuthenticate = `${url.pathname}@${expiry}`;
// The received MAC is Base64-encoded, so you 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, you 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 });
}
// you have verified the MAC and expiration time; you can now pass the request
// through.
return fetch(request);
}
addEventListener('fetch', event => {
event.respondWith(verifyAndFetch(event.request));
});
Generating signed requests
You can generate signed requests from within a Worker using the Web Crypto APIs.
For request URLs beginning with /generate/
, replace /generate/
with /verify/
, sign the resulting path with its timestamp, and return the full, signed URL in the response body.
async function generateSignedUrl(url) {
// You will 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, for example, the path or a query
// parameter.
const expirationMs = 60000;
const expiry = Date.now() + expirationMs;
// The signature will be computed for the pathname and the expiry timestamp.
// The two fields must be separated or padded to ensure that an attacker
// will not be able to use the same signature for other pathname/expiry pairs.
// The @ symbol is guaranteed not to appear in expiry, which is a (decimal)
// number, so you can safely use it as a separator here. When combining more
// fields, consider JSON.stringify-ing an array of the fields instead of
// concatenating the values.
const dataToAuthenticate = `${url.pathname}@${expiry}`;
const mac = await crypto.subtle.sign('HMAC', key, encoder.encode(dataToAuthenticate));
// `mac` is an ArrayBuffer, so you need to make a few changes 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);
}
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));
}
});