BlogImplementing JWT Authentication in Node.js
·Updated July 22, 2026·6 min read·JWTSecrets Team

Implementing JWT Authentication in Node.js

Learn to implement secure JWT authentication in Node.js using industry standard libraries. Follow our direct guide for Express.js integration.

Why JWT Authentication in Node.js Needs More Than `jwt.sign`

JSON Web Tokens carry authenticated identity between clients and Node.js APIs, but most JWT breaches come from weak secrets, missing claim validation, unsafe token storage, or refresh flows that never rotate credentials. This guide covers a threat-aware implementation with jsonwebtoken or jose, using JavaScript and TypeScript examples for Express, Fastify, or NestJS.

Generate a signing key with the JWT Secret Generator and review HS256 vs RS256 before choosing an algorithm.

Threat Model: What You Are Defending Against

Assume attackers can steal tokens from browser storage, replay refresh tokens, and forge JWTs if they obtain your JWT secret. Your service must enforce:

  • Key confidentiality — load secrets from env or a vault (Env Vars vs Vault).
  • Signature integrity — verify on every request; decoding alone proves nothing (JWT Validator).
  • Algorithm pinning — reject unexpected alg values, including none.
  • Claim binding — validate iss, aud, and exp so tokens for other services cannot replay here.
  • Session lifecycle — short access tokens plus rotated refresh tokens (Access Token vs Refresh Token).

JWT alone does not stop XSS or CSRF; use HttpOnly cookies, SameSite, CSP, and separate authorization checks. Treat every access token as a bearer credential: anyone holding it can act as that user until expiry or revocation, so minimize what you embed in the payload and never store passwords or payment data in claims (JWT payloads are only base64-encoded, not encrypted).

Project Setup and Configuration

npm install jsonwebtoken cookie-parser
npm install -D typescript @types/jsonwebtoken @types/cookie-parser

Load secrets from the environment per the Node.js secret guide:

const JWT_SECRET = process.env.JWT_SECRET;
const JWT_ISSUER = process.env.JWT_ISSUER ?? 'https://api.example.com';
const JWT_AUDIENCE = process.env.JWT_AUDIENCE ?? 'https://app.example.com';

if (!JWT_SECRET || JWT_SECRET.length < 32) {
  throw new Error('JWT_SECRET must be at least 256 bits');
}

TypeScript interfaces keep claim shapes explicit:

export interface AccessTokenClaims {
  sub: string;
  role: 'user' | 'admin';
  typ: 'access';
}

Issuing and Verifying Access Tokens

function signAccessToken(user) {
  return jwt.sign(
    { sub: user.id, role: user.role, typ: 'access' },
    JWT_SECRET,
    { algorithm: 'HS256', expiresIn: '15m', issuer: JWT_ISSUER, audience: JWT_AUDIENCE }
  );
}

Pin algorithm, issuer, and audience on every verify call:

export function verifyAccessToken(token: string): AccessTokenClaims {
  const payload = jwt.verify(token, JWT_SECRET, {
    algorithms: ['HS256'],
    issuer: JWT_ISSUER,
    audience: JWT_AUDIENCE,
  }) as jwt.JwtPayload & AccessTokenClaims;

  if (payload.typ !== 'access') throw new jwt.JsonWebTokenError('Invalid token type');
  return payload;
}

With jose, pass the same constraints to jwtVerify — never trust decoded JSON without cryptographic verification.

Refresh Token Flow Outline

1. User authenticates; server creates a session record.

2. Server returns a 15-minute access token and sets a refresh token in an HttpOnly cookie.

3. On expiry, client calls POST /auth/refresh; server validates refresh token, checks jti against a denylist, rotates refresh token, and issues a new access token.

4. On logout, revoke jti and clear the cookie.

Store refresh token hashes server-side; treat plaintext values like passwords.

Cookie Settings for Browser Clients

Misconfigured cookies are a top production failure (LocalStorage vs Cookies):

res.cookie('refresh_token', refreshToken, {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'strict',
  path: '/auth/refresh',
  maxAge: 7 * 24 * 60 * 60 * 1000,
});

Never expose refresh tokens to JavaScript. Keep access tokens in memory with short TTLs rather than localStorage.

Protected Routes, Error Handling, and Middleware

function requireAuth(req, res, next) {
  const header = req.headers.authorization;
  if (!header?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'missing_token' });
  }
  try {
    req.user = verifyAccessToken(header.slice(7));
    return next();
  } catch (err) {
    const code = err.name === 'TokenExpiredError' ? 'token_expired' : 'invalid_token';
    return res.status(401).json({ error: code });
  }
}

app.get('/api/profile', requireAuth, (req, res) => {
  res.json({ userId: req.user.sub, role: req.user.role });
});

Map verification failures to stable 401 responses; return 403 when the token is valid but roles are insufficient. A consistent error contract helps clients distinguish expired tokens (trigger refresh) from invalid ones (force re-login):

ConditionHTTPError code
Missing Bearer header401`missing_token`
Expired signature401`token_expired`
Bad signature or wrong aud/iss401`invalid_token`
Valid token, wrong role403`forbidden`

Log correlation IDs, never raw tokens or stack traces. Add requireRole('admin') after requireAuth for authorization.

Common Pitfalls

  • Trusting decoded payloads — verify signatures (Bearer Token proof is mandatory).
  • Using jwt.decode() in auth middlewaredecode() does not check the signature at all. It is only for inspecting already-verified tokens. Authentication must call jwt.verify() (or jose.jwtVerify) with an algorithm allowlist.
  • Omitting { algorithms: ['HS256'] } — without an allowlist, libraries may accept whatever alg is in the header, including none or algorithm-confusion tricks.
  • Skipping aud and iss — breaks multi-service and multi-tenant setups.
  • Accepting alg: none — always pass an algorithm allowlist.
  • Long-lived access tokens — use 15–60 minute token expiration with refresh for UX.
  • Same secret across environments — isolate and rotate per stage.

Debug with the JWT Encoder on non-production keys only.

Production Checklist

  • [ ] 256-bit+ CSPRNG secret in env or vault, never in git
  • [ ] algorithms, iss, aud, exp, and sub validated on every request
  • [ ] Custom typ claim separates access from refresh tokens
  • [ ] Refresh rotation with server-side jti tracking
  • [ ] HttpOnly, Secure, SameSite cookies for refresh tokens
  • [ ] Uniform 401/403 responses; rate limit /auth/login and /auth/refresh
  • [ ] Secret rotation plan with kid header support

See JWT Best Practices and JWT vs Session for broader architecture decisions.

Frequently Asked Questions

Should I use jsonwebtoken or jose in Node.js?

Both work in production. jsonwebtoken is ubiquitous; jose offers modern Web Crypto APIs and strong TypeScript support. Either way, pin algorithms and validate iss/aud explicitly.

Where should SPAs store access tokens?

Prefer in-memory storage with silent refresh via HttpOnly cookies. localStorage survives XSS and lets attackers exfiltrate tokens easily.

Do I need refresh tokens if access tokens expire in 15 minutes?

Yes, for browser sessions users expect persistence. Refresh tokens keep access tokens short while allowing server-side revocation via session records.

When should I switch from HS256 to RS256?

When multiple services verify tokens but only one signs them. RS256 lets verifiers hold a public key only; monoliths often stay on HS256 with a strong secret (HS256 vs RS256).

How do I invalidate a JWT before it expires in Node.js?

JWTs are stateless — you cannot invalidate them without a server-side store. Practical approaches: (1) keep access token lifetimes short (15 minutes) so leaks self-expire; (2) maintain a Redis (or DB) blocklist keyed by jti and check it on every request; (3) rotate refresh sessions server-side so stolen access tokens cannot be renewed.

Written by

JWTSecrets Team

Editorial Team

The JWTSecrets editorial team writes practical guides on JWT authentication, cryptographic key management, and browser-based security tooling. Our content is reviewed against IETF RFCs and current library documentation.