Timing-safe auth with Web Crypto

Updated on
Timing-safe auth with Web Crypto

I recently discovered a security improvement while working on authentication in a Cloudflare Worker. My endpoints were using simple string comparison (===) to validate auth tokens, which turns out to be vulnerable to timing attacks. Here’s how I fixed it and what I learned along the way.

Initially, I tried using crypto.subtle.timingSafeEqual, but this method isn’t available in the Web Crypto API (which means it’s not available in Cloudflare Workers or any browser-based JavaScript). Instead, I found we can achieve the same security using a double HMAC pattern. Here’s the implementation:

async function timingSafeEqual(bufferSource1: ArrayBuffer, bufferSource2: ArrayBuffer): Promise<boolean> {
    if (bufferSource1.byteLength !== bufferSource2.byteLength) {
        return false;
    }
    const algorithm = { name: 'HMAC', hash: 'SHA-256' };
    // @ts-ignore - We know HMAC generates a single key in Web Crypto API
    const key = await crypto.subtle.generateKey(algorithm, false, ['sign', 'verify']);
    const hmac = await crypto.subtle.sign(algorithm, key, bufferSource1);
    return await crypto.subtle.verify(algorithm, key, hmac, bufferSource2);
}

The key here is that HMAC comparison happens in constant time - meaning the check takes the same amount of time whether the tokens match completely, partially, or not at all. This prevents attackers from guessing tokens character by character based on response timing.

To make this easy to use across different endpoints, I wrapped it in a helper:

const validateAuthToken = async (token: string, secret: string): Promise<boolean> => {
    if (!token || !secret) {
        return false;
    }
    try {
        const encoder = new TextEncoder();
        const secretBuffer = encoder.encode(secret);
        const tokenBuffer = encoder.encode(token);
        return await timingSafeEqual(secretBuffer, tokenBuffer);
    } catch (e) {
        console.error('Error in token validation:', e);
        return false;
    }
};

Now I can validate tokens consistently across any endpoint:

const authToken = request.headers.get('Authorization')?.replace('Bearer ', '') || '';
if (!env.SECRET_KEY || !(await validateAuthToken(authToken, env.SECRET_KEY))) {
    return new Response('Unauthorized', { status: 401 });
}

I ran into an interesting TypeScript quirk along the way - generateKey can return either a CryptoKey or CryptoKeyPair, but with HMAC it’s always a single key. Rather than adding complex type definitions, a simple @ts-ignore worked since we know the runtime behavior is correct.

This implementation now protects against timing attacks across all my auth endpoints while keeping the code clean and maintainable. For anyone implementing auth token validation in Cloudflare Workers, I’d highly recommend using this pattern instead of direct string comparison.

Next steps? I’m thinking about adding request signing validation using similar constant-time comparison techniques. That would add another layer of security for sensitive endpoints.