An HS256 JWT verifier in 30 lines, no dependency
Why we removed jose from our Next.js stack, and the Web Crypto code that replaced it without breaking once since.
- Auth
- Engineering
Verifying a JWT is not an interesting problem. But living with a dependency that breaks every Next.js update is a daily one.
On one of our platforms, two Next.js apps need to verify JWTs signed by our NestJS API: a customer portal and an admin console. The secret is shared via an environment variable, the algorithm is HS256. No key rotation, no JWKS, no RSA. Just a cookie to read, a known secret, a payload to validate.
The problem with jose
First reflex: npm install jose. Three lines to verify, done.
Except Next.js 16 + Turbopack does not get along with jose. The library is ESM-only, its entry points to dist/webapi/, and depending on the resolved runtime (Edge or Node) the bundler picks the wrong variant. A full day chasing Module not found: jose/dist/webapi/index.js, then rm -rf .next, restart, same error.
transpilePackages: ['jose'] helps in webpack, but Turbopack in dev ignores it. jose@v4 (CJS) works in webpack but breaks the Edge runtime. We went in circles.
At that point, two options: keep fighting a dep, or look at what verifying an HS256 JWT actually involves.
What we actually need
An HS256 JWT is three parts separated by dots: header, payload, signature. The signature is HMAC-SHA256(secret, header + "." + payload) encoded in base64url. Verifying means:
- split on the dots
- recompute the HMAC over
header.payload - compare with the received signature
- decode the payload as JSON
- check
expand the expected shape
None of those needs a library. The Web Crypto API (crypto.subtle) is native in Node 18+, in browsers, in Edge runtime, in Bun, in Deno.
The code
function base64urlToBytes(str: string): Uint8Array {
const base64 = str.replace(/-/g, "+").replace(/_/g, "/");
const padded = base64.padEnd(
base64.length + ((4 - (base64.length % 4)) % 4),
"="
);
return Uint8Array.from(atob(padded), (c) => c.charCodeAt(0));
}
export async function verifyJwt(token: string): Promise<Payload | null> {
try {
const parts = token.split(".");
if (parts.length !== 3) return null;
const [headerB64, payloadB64, signatureB64] = parts;
const key = await crypto.subtle.importKey(
"raw",
new TextEncoder().encode(process.env.JWT_SECRET!),
{ name: "HMAC", hash: "SHA-256" },
false,
["verify"]
);
const valid = await crypto.subtle.verify(
"HMAC",
key,
base64urlToBytes(signatureB64).buffer as ArrayBuffer,
new TextEncoder().encode(`${headerB64}.${payloadB64}`)
);
if (!valid) return null;
const payload = JSON.parse(
new TextDecoder().decode(base64urlToBytes(payloadB64))
);
if (payload.exp && Date.now() / 1000 > payload.exp) return null;
return payload;
} catch {
return null;
}
}
Thirty lines. No dependency, no dist/webapi/ confusion, no ESM/CJS crossing. Runs in Node 18+, Edge runtime, the browser — anywhere with Web Crypto.
The details that matter
base64urlToBytes: atob does not read base64url (with - and _ instead of + and /). We remap and pad. Five lines, never to touch again.
.buffer as ArrayBuffer: recent TypeScript sees Uint8Array<ArrayBufferLike> instead of BufferSource. The cast is ugly but documented.
Shape validation: we decode the JSON and check the payload shape (required fields, expected types). A malformed JWT was never signed by our secret, so the signature would have failed already — but if someone signed a malformed payload with our secret, we should still refuse it. Three typeof checks, no more.
No signing here: this code verifies. Signing happens server-side in NestJS using @nestjs/jwt. The asymmetry is fine: signing happens once (at login), verifying on every request. @nestjs/jwt is justified for signing (standard claims, refresh, rotation), not for verifying.
The trap
The secret is read from process.env.JWT_SECRET. If you add a default fallback “for dev convenience”, a missing variable in production goes unnoticed. We added a startup check in each app’s server layout: if process.env.NODE_ENV === 'production' and the variable is missing, throw. Thirty seconds to write, six months saved on a bug we never have.
Six months later
The code has not moved. No Next.js upgrade broke it. No Turbopack hotfix. No runtime change.
The only thing we would change: extract base64urlToBytes into a shared file, because we use it in tests too. Five minutes.
The lesson is not “never use a JWT library”. It is: before installing a dependency, look at what it replaces in the platform. If the runtime’s native API does the job, the library is a net cost.