typescript 109 lines · 3 tabs

Signing and Verifying JWT Access Tokens with Express Middleware

Shared by codesnips Sep 2026
3 tabs
import jwt, { JwtPayload, SignOptions } from 'jsonwebtoken';

const SECRET = process.env.JWT_SECRET as string;
const ISSUER = 'auth.example.com';
const AUDIENCE = 'api.example.com';

export interface AccessClaims {
  sub: string;
  role: string;
}

export class TokenError extends Error {}

export function signAccessToken(claims: AccessClaims): string {
  const options: SignOptions = {
    expiresIn: '15m',
    issuer: ISSUER,
    audience: AUDIENCE,
  };
  return jwt.sign({ ...claims, type: 'access' }, SECRET, options);
}

export function verifyAccessToken(token: string): AccessClaims {
  let payload: string | JwtPayload;
  try {
    payload = jwt.verify(token, SECRET, { issuer: ISSUER, audience: AUDIENCE });
  } catch (err) {
    throw new TokenError('invalid or expired token');
  }

  if (typeof payload === 'string' || payload.type !== 'access') {
    throw new TokenError('token is not an access token');
  }

  return { sub: String(payload.sub), role: String(payload.role) };
}
3 files · typescript Explain with highlit

This snippet shows how short-lived JWT access tokens are issued and validated in an Express API, split cleanly between a token service that owns the crypto and middleware that enforces it on routes. The separation matters: the signing/verifying logic should live in one place so key rotation, clock skew, and claim conventions are consistent everywhere, while route handlers stay thin.

In tokenService.ts, signAccessToken embeds only the minimal claims needed downstream — a stable subject (sub), a role, and a type marker — plus a short expiresIn. The explicit type: 'access' claim is a small but important defense: it prevents a refresh token from being accepted where an access token is expected, which is a common privilege-escalation mistake when both are signed with the same secret. verifyAccessToken wraps jwt.verify and enforces the issuer and audience, so a valid token minted for a different service is rejected. Errors from jsonwebtoken (expiry, bad signature, malformed input) are normalized into a single TokenError so callers do not have to pattern-match on library internals.

The authenticate middleware in authMiddleware.ts pulls the bearer token from the Authorization header, verifies it, and attaches a typed req.user. Because it never throws, it forwards failures to Express's error pipeline via next with the right 401 semantics. The companion requireRole is a higher-order middleware — it returns a middleware configured with the allowed roles — which keeps authorization declarative at the route level. Splitting authentication (who are you) from authorization (what may you do) is deliberate: the token can be valid while the role is still insufficient, and those map to 401 versus 403 respectively.

The Express type augmentation for Request gives req.user real types across the app instead of casting in every handler. authRoutes.ts ties it together: a login route calls signAccessToken after credential checks, and a protected route composes authenticate with requireRole('admin'). The trade-off of stateless JWTs is that they cannot be revoked before expiry, which is why the access token lifetime is kept short and paired with a separately managed refresh flow. Keeping the secret in env and validating issuer/audience are the guardrails that make this pattern safe in practice.


Related snips

Share this code

Here's the card — post it anywhere.

Signing and Verifying JWT Access Tokens with Express Middleware — share card
Link copied