jwt
How to Validate JWT Signature Without a Library
Why verify a JWT signature manually
Libraries like jsonwebtoken, jose, and jwt-decode handle JWT operations in production. But understanding manual verification is valuable in three situations: you need to implement verification in an environment without npm (a Cloudflare Worker, a Deno Deploy function, or a browser extension); you want to audit what a library actually does under the hood; or you need a lightweight verification in a serverless function where bundle size matters.
The Web Crypto API, available natively in all modern browsers and in Node.js 18+, provides everything needed to verify both HS256 (HMAC-SHA256) and RS256 (RSA-PKCS1-v1_5-SHA256) signatures without any external dependency.
Never skip signature verification in production. Decoding a JWT (reading its claims) is not the same as verifying it. An attacker can craft any payload they want — only a valid signature proves the token was issued by your trusted authority.
JWT structure refresher
A JWT is three base64url-encoded segments joined by dots: header.payload.signature. The header contains the algorithm (alg) and token type (typ). The payload contains claims like sub (subject), exp (expiry), iss (issuer), and any custom fields. The signature is computed over the string headerB64.payloadB64 using the algorithm specified in the header.
Base64url is similar to base64 but uses - instead of + and _ instead of /, and omits the = padding. You must account for this when decoding manually.
Base64url decode helper
Both the header and payload are base64url-encoded JSON. Before you can read them, you need a decode function that reverses the base64url encoding:
function base64urlDecode(str) {
// Replace base64url characters with standard base64
const base64 = str.replace(/-/g, '+').replace(/_/g, '/');
// Pad to a multiple of 4
const padded = base64 + '=='.slice(0, (4 - base64.length % 4) % 4);
return atob(padded);
}
function decodeJwtParts(token) {
const [headerB64, payloadB64, signatureB64] = token.split('.');
return {
header: JSON.parse(base64urlDecode(headerB64)),
payload: JSON.parse(base64urlDecode(payloadB64)),
signatureB64,
signingInput: `${headerB64}.${payloadB64}`,
};
}The signingInput is the exact string that was signed — the concatenated base64url header and payload separated by a dot. Do not decode it; you need the raw base64url string to verify the signature.
Verifying HS256 (HMAC-SHA256) with Web Crypto
HS256 tokens use a shared secret. The same secret that signed the token is used to verify it. This algorithm is common for internal service-to-service tokens where both sides share the secret securely.
async function verifyHS256(token, secret) {
const [headerB64, payloadB64, signatureB64] = token.split('.');
const signingInput = `${headerB64}.${payloadB64}`;
const encoder = new TextEncoder();
// Import the secret as a CryptoKey
const cryptoKey = await crypto.subtle.importKey(
'raw',
encoder.encode(secret),
{ name: 'HMAC', hash: 'SHA-256' },
false, // not extractable
['verify']
);
// Decode the base64url signature to Uint8Array
const rawSig = atob(signatureB64.replace(/-/g, '+').replace(/_/g, '/'));
const sigBytes = Uint8Array.from(rawSig, c => c.charCodeAt(0));
const valid = await crypto.subtle.verify(
'HMAC',
cryptoKey,
sigBytes,
encoder.encode(signingInput)
);
if (!valid) throw new Error('Invalid JWT signature');
const payload = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/')));
// Always validate expiry
const now = Math.floor(Date.now() / 1000);
if (payload.exp && now > payload.exp) {
throw new Error(`JWT expired at ${new Date(payload.exp * 1000).toISOString()}`);
}
return payload;
}
// Usage
const payload = await verifyHS256(token, process.env.JWT_SECRET);
console.log('Verified payload:', payload);crypto.subtle.verify returns a boolean — it does not throw on a bad signature; you must check the return value explicitly. The function above throws an error on failure so callers can use it in try/catch blocks.
Verifying RS256 (RSA) with a public key
RS256 tokens are signed with a private key and verified with the corresponding public key. This is the algorithm used by most identity providers — Auth0, Firebase, Google, Microsoft Entra — because the public key can be distributed freely.
async function verifyRS256(token, publicKeyPem) {
const [headerB64, payloadB64, signatureB64] = token.split('.');
const signingInput = `${headerB64}.${payloadB64}`;
// Strip PEM headers and decode
const pemBody = publicKeyPem
.replace(/-----BEGIN PUBLIC KEY-----/, '')
.replace(/-----END PUBLIC KEY-----/, '')
.replace(/s/g, '');
const keyBytes = Uint8Array.from(atob(pemBody), c => c.charCodeAt(0));
const cryptoKey = await crypto.subtle.importKey(
'spki',
keyBytes,
{ name: 'RSASSA-PKCS1-v1_5', hash: 'SHA-256' },
false,
['verify']
);
const rawSig = atob(signatureB64.replace(/-/g, '+').replace(/_/g, '/'));
const sigBytes = Uint8Array.from(rawSig, c => c.charCodeAt(0));
const encoder = new TextEncoder();
const valid = await crypto.subtle.verify(
'RSASSA-PKCS1-v1_5',
cryptoKey,
sigBytes,
encoder.encode(signingInput)
);
if (!valid) throw new Error('Invalid JWT signature');
const payload = JSON.parse(atob(payloadB64.replace(/-/g, '+').replace(/_/g, '/')));
const now = Math.floor(Date.now() / 1000);
if (payload.exp && now > payload.exp) throw new Error('JWT expired');
return payload;
}For JWKS-based verification (where the public key is fetched from a URL like /.well-known/jwks.json), you would first fetch the key set, match the kid (key ID) from the JWT header to the correct JWK entry, then convert the JWK to a CryptoKey using importKey with format 'jwk'.
Critical security checks to never skip
Signature verification alone is not sufficient. Several attacks exploit missing secondary checks. The alg:none attack exploits parsers that accept a JWT with alg set to 'none' and no signature — always verify that the alg header matches your expected algorithm and reject anything else.
- Check alg header — reject tokens where alg is 'none' or differs from your expected algorithm
- Check exp — reject tokens where the current time exceeds the exp claim
- Check iss — reject tokens from issuers you do not trust
- Check aud — reject tokens not intended for your service
- Check nbf (if present) — reject tokens not yet valid
- Never accept a token without a signature, even if the payload looks correct
The 'alg:none' attack: an attacker modifies a JWT to set alg: 'none' and removes the signature. A naive verifier that trusts the alg header will skip verification entirely. Always hardcode your expected algorithm — never read it from the token itself.
When to use a library vs manual verification
Manual verification is best suited for edge environments (Cloudflare Workers, Deno), small serverless functions where bundle size matters, and audit/learning purposes. For most production applications, a well-maintained library like jose (works in browser, Node.js, Deno, and edge) is the right choice because it handles algorithm negotiation, key rotation, JWKS fetching, and claim validation in a tested, maintained package.
The code in this guide demonstrates the fundamentals that every library implements under the hood. Understanding it makes you a better consumer of those libraries and helps you debug token issues that go beyond what error messages tell you.
Using Just Formatter to inspect tokens during development
During development and debugging, you rarely need to verify the signature — you need to inspect the claims. Is the exp claim set correctly? Does the aud match your service? Is the custom role claim present? Just Formatter's JWT Decoder decodes any token instantly, displaying the header algorithm, all payload claims with human-readable timestamps, and the signature segment.
Paste any token into the decoder to confirm its structure before writing verification code. This is especially useful when working with tokens from identity providers, where the claim structure is not always documented clearly.
