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. let base64Mac = btoa(String.fromCharCode(...new Uint8Array(mac))); // must convert "+" to "-" as urls encode "+" as " " base64Mac = base64Mac.replaceAll("+", "-") 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)); }
});