Authentication basics

A minimal auth flow: sign a token on login, verify it on every protected request, reject expired or tampered tokens automatically.

This example uses HS256 (symmetric) — the simplest and fastest path when the same service signs and verifies. If you're building a system where third parties need to verify tokens without being able to sign them, see JWKS endpoint instead.

Setup

secrets.ts
import { generateJWK } from "unjwt/jwk";

// Generate once, persist. Treat like any other secret.
const signingKey = await generateJWK("HS256", { kid: "primary-2025" });
// { kty: "oct", k: "...", alg: "HS256", kid: "primary-2025" }

In production, load this key from a secrets manager (or at minimum, process.env). Do not generate a fresh key on every boot — all existing tokens would instantly become invalid.

env-loading.ts
import type { JWK_oct } from "unjwt/jwk";
import { isSymmetricJWK } from "unjwt/utils";

const parsed = JSON.parse(process.env.JWT_SIGNING_KEY!);
if (!isSymmetricJWK(parsed)) {
  throw new Error("JWT_SIGNING_KEY must be a symmetric JWK");
}
const signingKey: JWK_oct = parsed;

Issue a token (login)

login.ts
import { sign } from "unjwt/jws";

async function issueAccessToken(userId: string, roles: string[]) {
  return sign({ sub: userId, roles }, signingKey, {
    expiresIn: "15m",
    protectedHeader: {
      typ: "access+jwt",
    },
  });
}

What unjwt adds to the payload automatically:

  • iat — current time.
  • expiat + 900 (15 minutes from expiresIn).
  • kid — copied from signingKey.kid into the header.

What your code controls:

  • sub — subject, typically the user id.
  • roles / scopes / whatever else your app needs.
  • typ: "access+jwt" — a distinct type so a refresh token from the same app can't be mistaken for an access token (RFC 8725 §3.11 recommendation).

Verify on a protected route

middleware.ts
import { verify, type JWSVerifyResult } from "unjwt/jws";

async function requireAuth(
  request: Request,
): Promise<JWSVerifyResult<{ sub: string; roles: string[] }>> {
  const authHeader = request.headers.get("authorization");
  if (!authHeader?.startsWith("Bearer ")) {
    throw new Response("Unauthorized", { status: 401 });
  }
  const token = authHeader.slice("Bearer ".length);

  try {
    return await verify<{ sub: string; roles: string[] }>(token, signingKey, {
      audience: "my-api",
      issuer: "my-app",
      typ: "access+jwt", // must match what we set on issue
      maxTokenAge: "15m", // defense-in-depth on top of exp
    });
  } catch (error) {
    throw new Response(`Invalid token: ${(error as Error).message}`, { status: 401 });
  }
}

Then in a route:

route.ts
async function getProfile(request: Request) {
  const { payload } = await requireAuth(request);
  return Response.json({ userId: payload.sub, roles: payload.roles });
}

What's checked automatically

When you call verify() with a payload that's a JSON object, unjwt runs these checks without needing you to opt in:

CheckWhat it rejects
SignatureToken was tampered with, or signed by the wrong key.
alg allowlistWrong algorithm — derived from signingKey.alg.
expToken is past its expiration.
nbfToken is used before its "not before" time.
iatNot a finite number (if present).

These are the checks that protect you from the classic JWT pitfalls (alg: none attack, algorithm confusion, expired-token reuse). You don't need to pass algorithms explicitly because signingKey.alg is set.

Adding issuer / audience

These are defence-in-depth. They catch bugs where the same key is accidentally reused across services:

await sign({ sub: userId, roles, iss: "my-app", aud: "my-api" }, signingKey, {
  expiresIn: "15m",
  protectedHeader: { typ: "access+jwt" },
});

Now a token signed for my-api can't verify as a token for admin-api, even if both use the same signing key during a shared-infrastructure transition.

Next steps

See also