Skip to content
4 min read Moustakime KIFIA

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:

  1. split on the dots
  2. recompute the HMAC over header.payload
  3. compare with the received signature
  4. decode the payload as JSON
  5. check exp and 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.

Keep reading

A few related articles to extend the topic and keep the internal linking strong.

7 min

RabbitMQ in a SaaS platform: where to use it, and why

When to use RabbitMQ in a SaaS platform, which flows should go through it, and how it helps decouple provisioning, notifications and selected business workflows without event-driving the whole system.

  • Integrations
  • Engineering
  • Architecture
Read the note
2 min

Building a full stack SaaS as lead tech

A look at the technical choices behind Cercly — from the dev environment to production, across microservices, mobile, and AI-assisted delivery.

  • Architecture
  • Engineering
  • DevOps
  • SaaS
Read the note