RecipesAuthentication

JWT Authentication (Functional)

Verify a bearer JWT in plain middleware using `jose` — no class runtime, no legacy `jsonwebtoken`.

Verify a JWT on incoming requests using functional middleware. Uses jose (modern, multi-runtime) — not jsonwebtoken.

Before you start

  • Node.js 18+, Bun 1.0+, or Deno 1.30+
  • A JWT_SECRET environment variable (32+ characters)

Why jose?

jose is built on the Web Crypto API — it works on Node, Bun, Deno, Cloudflare Workers, Vercel Edge, and browsers. Unlike jsonwebtoken (Node-only, callback-based), jose is async, TypeScript-first, and RFC compliant.

For guard-based auth with role checks and refresh tokens, see the Authentication guide instead.

Setup

npm install jose

Usage

1. Auth middleware

src/middleware/require-auth.ts
import { jwtVerify } from 'jose';
import { UnauthorizedError } from 'nextrush';
import type { Middleware } from 'nextrush';

const JWT_SECRET = new TextEncoder().encode(
  process.env.JWT_SECRET ?? '',
);

if (JWT_SECRET.length < 32) {
  throw new Error('JWT_SECRET env variable must be at least 32 characters');
}

interface JwtPayload {
  sub: string;
  email: string;
}

export const requireAuth: Middleware = async (ctx) => {
  const header = ctx.get('authorization');

  if (!header || !header.startsWith('Bearer ')) {
    throw new UnauthorizedError('Missing bearer token');
  }

  const token = header.slice('Bearer '.length);

  try {
    const { payload } = await jwtVerify(token, JWT_SECRET);
    ctx.state.user = { id: payload.sub as string, email: payload.email as string };
  } catch {
    throw new UnauthorizedError('Invalid or expired token');
  }

  await ctx.next();
};

TextEncoder

jose requires keys as Uint8Array. new TextEncoder().encode() converts a string secret — same across all runtimes. No Buffer.from() needed.

2. Use it

src/index.ts
import { createApp, listen, errorHandler } from 'nextrush';
import { requireAuth } from './middleware/require-auth';

const app = createApp();

app.use(errorHandler());

app.get('/public', (ctx) => {
  ctx.json({ message: 'anyone can see this' });
});

app.get('/me', requireAuth, (ctx) => {
  ctx.json({ user: ctx.state.user });
});

listen(app, 8080);

Route-level, not global

Register requireAuth per-route or per-router — not app.use(requireAuth). A global registration blocks public routes like /health or /public.

3. Signing tokens (for tests or login)

import { SignJWT } from 'jose';

const token = await new SignJWT({ sub: '1', email: 'a@example.com' })
  .setProtectedHeader({ alg: 'HS256' })
  .setIssuedAt()
  .setExpirationTime('1h')
  .sign(new TextEncoder().encode(process.env.JWT_SECRET!));

Compatibility

Runtimejosejsonwebtoken
Node
Bun
Deno
CF Workers
Edge
Browser

Troubleshooting

ErrorReasonFix
jose.JWTExpiredToken past expIssue new token
jose.JWSSignatureVerificationFailedWrong secretCheck JWT_SECRET matches signer
payload.sub as string returns undefinedClaim not in payloadAdd claim when signing
TextEncoder is not definedOld Node versionUse Node 18+ or polyfill

  • 🛠 Guide: Authentication — full guard-based auth with refresh tokens and RBAC
  • 🧠 Concept: Contextctx.get(), ctx.state, and ctx.next()
  • 🧠 Concept: Middleware — execution order and registration
  • 🍳 Recipe: Per-User Rate Limiting — pair with auth to limit by user
Was this helpful?

On this page