Skip to content

Security

JWT Authentication in Node.js, Explained Properly

What a JWT actually is, why the signature matters more than the payload, and the concrete decisions — storage, expiry, algorithm, revocation — that separate a working login from a safe one.

4 min read12 views
  • #jwt
  • #authentication
  • #nodejs
  • #security
  • #backend

Most JWT tutorials stop at jwt.sign() and jwt.verify(). That's about 20% of what you need, and the missing 80% is where the security actually lives.

What a token really is

A JWT is three base64url segments separated by dots:

eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpZCI6IjY5YjE4ZjJhIiwiaWF0IjoxNzUwMH0.4Xk2…
└──────── header ────────┘ └──────── payload ────────┘ └── signature ──┘

Decode the first two and you get plain JSON:

{ "alg": "HS256", "typ": "JWT" }
{ "id": "69b18f2a", "role": "admin", "iat": 1750000000, "exp": 1750604800 }

This is the single most important fact about JWTs: the payload is encoded, not encrypted. Anyone holding the token can read it — no key required, jwt.decode() or jwt.io will do it.

So never put anything in there you wouldn't print on a postcard. No passwords, no PII beyond an identifier, no internal flags you'd rather not expose.

What the signature gives you is integrity: proof the payload hasn't been altered, because producing a valid signature requires the secret. Change one character of the payload and verification fails.

Signing and verifying

lib/auth.ts
import jwt from 'jsonwebtoken';
 
const SECRET = process.env.JWT_SECRET;
if (!SECRET) throw new Error('JWT_SECRET is not set');
 
export function signToken(userId: string) {
  return jwt.sign({ id: userId }, SECRET, {
    expiresIn: '15m',
    algorithm: 'HS256',
    issuer: 'adityabhavar.me',
    audience: 'adityabhavar.me',
  });
}
 
export function verifyToken(token: string) {
  return jwt.verify(token, SECRET, {
    algorithms: ['HS256'],           // ← pin this, see below
    issuer: 'adityabhavar.me',
    audience: 'adityabhavar.me',
  }) as { id: string };
}

That algorithms: ['HS256'] array is not decoration. Without it, verify trusts the alg field in the token itself — which the attacker controls. Historically that enabled two real attacks: setting alg: "none" to skip verification entirely, and switching an RS256 setup to HS256 so the public key gets used as an HMAC secret. Pin the algorithm and both disappear.

The secret matters too. your_jwt_secret_key_change_this is brute-forceable in seconds. Generate a real one:

node -e "console.log(require('crypto').randomBytes(48).toString('base64url'))"

Where the token lives

This decision matters more than anything about the token itself.

localStorage httpOnly cookie
Readable by JavaScript yes no
Survives XSS no yes
Sent automatically no yes
CSRF exposure none needs sameSite
Works cross-domain easily needs configuration

If an attacker lands any XSS on your page, a token in localStorage is theirs. An httpOnly cookie can't be read by script at all.

import { cookies } from 'next/headers';
 
(await cookies()).set('token', signToken(user.id), {
  httpOnly: true,
  secure: process.env.NODE_ENV === 'production',
  sameSite: 'lax',
  path: '/',
  maxAge: 60 * 15,
});

sameSite: 'lax' means the cookie is sent on top-level navigation but not on cross-site form posts or subresource requests — which blocks the common CSRF shapes without breaking normal links. If you genuinely need sameSite: 'none', you also need explicit CSRF tokens.

Expiry, and the revocation problem

Here's the trade-off nobody mentions up front: a signed JWT is valid until it expires, and you cannot un-issue it.

If a user logs out, changes their password, or gets their access revoked, any token already in the wild keeps working. There's no server-side session to delete — that's the entire point of stateless auth, and it's also its main weakness.

Three ways to handle it:

Short expiry plus a refresh token. Access tokens live 15 minutes; a longer-lived refresh token mints new ones. Damage is bounded to one window. This is the standard answer.

A denylist. Store revoked token IDs in Redis with a TTL matching their remaining lifetime:

const payload = jwt.sign({ id: user.id, jti: crypto.randomUUID() }, SECRET, { expiresIn: '15m' });
 
// On logout
await redis.set(`revoked:${jti}`, '1', { EX: 900 });
 
// On verify
if (await redis.get(`revoked:${claims.jti}`)) throw new Error('Token revoked');

Honest caveat: this reintroduces a lookup on every request, which is the statefulness you were trying to avoid. It's a reasonable trade when logout must be immediate.

A token version on the user record. Bump tokenVersion on password change and include it in the payload; reject tokens whose version is stale. Cheap if you're already loading the user.

The middleware

middleware.ts
import { NextResponse, type NextRequest } from 'next/server';
import { jwtVerify } from 'jose';
 
const secret = new TextEncoder().encode(process.env.JWT_SECRET);
 
export async function middleware(req: NextRequest) {
  const token = req.cookies.get('token')?.value;
  if (!token) return NextResponse.redirect(new URL('/login', req.url));
 
  try {
    const { payload } = await jwtVerify(token, secret, { algorithms: ['HS256'] });
    const headers = new Headers(req.headers);
    headers.set('x-user-id', String(payload.id));
    return NextResponse.next({ request: { headers } });
  } catch {
    const res = NextResponse.redirect(new URL('/login', req.url));
    res.cookies.delete('token');   // clear the bad cookie so it stops retrying
    return res;
  }
}
 
export const config = { matcher: ['/dashboard/:path*', '/api/protected/:path*'] };

Note jose rather than jsonwebtoken — Next.js middleware runs on the Edge runtime, which has no Node crypto module. jose uses Web Crypto and works in both.

Mistakes I've made or reviewed

Putting the whole user object in the payload. Every request now carries a kilobyte of stale data. Store the ID; load the user when you need them.

Long-lived access tokens "for convenience." A 30-day access token is a 30-day breach window. 15 minutes plus a refresh token gives the same UX with a fraction of the exposure.

Trusting role from the token for sensitive operations. It was true when the token was minted. If you demoted the user five minutes ago, the token still says admin. For destructive actions, check the database.

Different errors for "user not found" and "wrong password." That turns your login form into a user-enumeration endpoint. One message for both.

No rate limit on login. Bcrypt is deliberately slow, which makes an unthrottled login endpoint a CPU exhaustion vector as well as a credential-stuffing target.

When not to use JWTs

For a normal server-rendered web app with one backend, session cookies backed by a store are simpler and safer. Revocation is a DELETE. There's no expiry-window trade-off. There's no algorithm confusion.

JWTs earn their complexity when you have multiple services that need to verify identity without sharing a session store, or a mobile client, or a third-party API. Reach for them because you have that problem — not because the tutorial did.

The mental model to keep: a JWT is a signed claim with an expiry date, not a session. Design around that and the rest follows.