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
algvalues, includingnone. - Claim binding — validate
iss,aud, andexpso 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-parserLoad 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):
| Condition | HTTP | Error code |
|---|---|---|
| Missing Bearer header | 401 | `missing_token` |
| Expired signature | 401 | `token_expired` |
| Bad signature or wrong aud/iss | 401 | `invalid_token` |
| Valid token, wrong role | 403 | `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 middleware —decode()does not check the signature at all. It is only for inspecting already-verified tokens. Authentication must calljwt.verify()(orjose.jwtVerify) with an algorithm allowlist. - Omitting
{ algorithms: ['HS256'] }— without an allowlist, libraries may accept whateveralgis in the header, includingnoneor algorithm-confusion tricks. - Skipping
audandiss— 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, andsubvalidated on every request - [ ] Custom
typclaim separates access from refresh tokens - [ ] Refresh rotation with server-side
jtitracking - [ ] HttpOnly, Secure, SameSite cookies for refresh tokens
- [ ] Uniform 401/403 responses; rate limit
/auth/loginand/auth/refresh - [ ] Secret rotation plan with
kidheader 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.