HMAC-SHA256 with Web Crypto API in JavaScript
Sign and verify a payload using HMAC-SHA256 with only the browser's native cryptographic API β
crypto.subtle. No external libraries, no dependencies. Works in all modern browsers and Node.js 18+.
The problem
You need to authenticate a token or verify data integrity,
but don't want to add dependencies like crypto-js or jsonwebtoken.
The Web Crypto API is already in the browser β just use it.
Sign a payload
/** * Firma un payload con HMAC-SHA256. * @param {string} secret β chiave segreta condivisa * @param {string} payload β stringa da firmare (es. JSON.stringify di un oggetto) * @returns {Promise<string>} firma in hex */ async function hmacSign(secret, payload) { const enc = new TextEncoder(); const key = await crypto.subtle.importKey( 'raw', enc.encode(secret), { name: 'HMAC', hash: 'SHA-256' }, false, // non estraibile ['sign'] ); const sig = await crypto.subtle.sign('HMAC', key, enc.encode(payload)); // Converte ArrayBuffer β stringa hex return [...new Uint8Array(sig)] .map(b => b.toString(16).padStart(2, '0')) .join(''); }
Verify the signature
/** * Verifica che tokenHex corrisponda a HMAC-SHA256(secret, payload). * Usa una comparazione a tempo costante per prevenire timing attacks. * @returns {Promise<boolean>} */ async function hmacVerify(secret, payload, tokenHex) { const expected = await hmacSign(secret, payload); // Confronto a lunghezza fissa β evita timing attacks if (expected.length !== tokenHex.length) return false; let diff = 0; for (let i = 0; i < expected.length; i++) { diff |= expected.charCodeAt(i) ^ tokenHex.charCodeAt(i); } return diff === 0; }
Full example: signed URL with expiry
const SECRET = 'chiave-segreta-condivisa'; // ββ MITTENTE: crea token ββββββββββββββββββββββββββββββ async function buildAuthUrl(userId, targetUrl) { const payload = JSON.stringify({ uid: userId, exp: Date.now() + 5 * 60 * 1000 // scade in 5 minuti }); const b64 = btoa(payload); const sig = await hmacSign(SECRET, b64); const token = `${b64}.${sig}`; return `${targetUrl}?auth=${encodeURIComponent(token)}`; } // ββ DESTINATARIO: legge e verifica token ββββββββββββββ async function parseAuthToken(token) { const [b64, sig] = token.split('.'); if (!b64 || !sig) throw new Error('Token malformato'); const valid = await hmacVerify(SECRET, b64, sig); if (!valid) throw new Error('Firma non valida'); const data = JSON.parse(atob(b64)); if (Date.now() > data.exp) throw new Error('Token scaduto'); return data; // { uid: '...', exp: ... } }
When to use it: cross-app authentication, webhook validation, expiring signed URLs, data integrity verification between client and server.
Warning: the secret in client-side code is visible to anyone who inspects the bundle. For public apps, sign server-side using a backend (e.g. Firebase Cloud Functions or a Node.js endpoint).
Why crypto.subtle instead of btoa()?
btoa() is just Base64 encoding β it signs nothing and guarantees no integrity.
crypto.subtle.sign() uses real cryptographic primitives: the key is imported
in a non-extractable form, operations happen in a protected context of the JS engine.
- HMAC = Hash-based Message Authentication Code
- SHA-256 = 256-bit hash function
- The result is deterministic: same secret + same payload = same signature
- Without the secret it is computationally infeasible to forge a valid signature