JWT Authentication in Node.js: What the Tutorials Get Wrong

JWT Authentication in Node.js: What the Tutorials Get Wrong
Brandon Perfetti

Technical PM + Software Engineer

Topics:authenticationSecurityjwt
Tech:Node.jsJSON Web Tokens (JWT)HTTP cookies

I used to follow the same simple tutorial: issue a long-lived JWT, store it in localStorage, and call it a day. Then an account was hijacked and I realized those tutorials skipped several essential security patterns. This article fixes that by explaining what the tutorials get wrong and describing practical, implementation-friendly patterns for Node.js apps: where to store tokens, how to rotate refresh tokens, how to use httpOnly cookies correctly, and how to invalidate tokens efficiently. No fluff — concrete patterns you can adopt today.

1) The core misunderstandings tutorials make

Three recurring simplifications in JWT tutorials cause security gaps: (a) treating JWTs as a complete stateless replacement for sessions, (b) recommending localStorage for token storage, and (c) keeping refresh tokens long-lived without rotation or server-side tracking. These choices ignore practical threats: XSS exposing localStorage, stolen long-lived refresh tokens granting persistent access, and no way to revoke compromised tokens quickly.

You must accept two realities: JWTs are bearer tokens (anyone with the token can use it), and even a signed JWT doesn't mean you should avoid server-side state. Good designs mix JWTs with small server-side state to solve revocation and rotation problems.

  • JWT != magic sessionless security: keep minimal server state when you need revocation.
  • localStorage exposes tokens to XSS. Assume XSS is possible.
  • Long-lived refresh tokens without rotation are high-value targets—protect and rotate them.

2) Where to store tokens (practical rule-set)

Choose storage depending on token type. Short-lived access tokens (minutes) should be kept in memory on SPA apps or in httpOnly cookies for server-rendered apps. Refresh tokens must be protected: store them as httpOnly, Secure cookies (or in a server-side store with a session ID in the cookie). Avoid localStorage for any token that grants access beyond a single request.

Why in-memory for access tokens? In-memory (e.g., React state) reduces persistence across reloads and makes theft harder for simple XSS vectors. Pair in-memory access tokens with an httpOnly refresh cookie so the client can request new access tokens without exposing the refresh token to JavaScript.

  • Access token: short TTL (5–15 minutes), stored in memory or httpOnly cookie.
  • Refresh token: store in httpOnly, Secure, SameSite=strict/strict-ish cookie or as an opaque server-side session keyed by a cookie.
  • Never store refresh tokens in localStorage or accessible JavaScript if you want rotation and reuse detection.

3) Refresh token rotation and reuse detection (the minimal protocol)

Rotation means that every time you use a refresh token to get a new access token, the server issues a new refresh token and invalidates the previous one. This converts a stolen refresh token into a one-time-use token: if an attacker replays a token that's already been rotated, you detect reuse and can revoke the session.

Implement rotation with an identifier (tokenId) stored hashed in the database and associated with a session row. When a refresh request arrives, look up tokenId, verify it's unused, mark it used (or replace with new id), issue new refresh+access tokens, and store the new tokenId hashed. If a tokenId lookup fails or token is already used, treat it as a compromise and revoke the session and optionally all sessions for that user.

  • Issue both an access JWT and an opaque refresh token (random, high-entropy ID).
  • Store hashed refresh token ID in DB with session metadata (user, device, ip, issuedAt).
  • On refresh: verify, rotate (replace the stored hashed token), and log the rotation event.
  • If a presented refresh token is unknown or already rotated, consider it reused and revoke.

4) Using httpOnly cookies safely (CSRF and SameSite considerations)

httpOnly cookies prevent JavaScript from directly reading tokens, protecting refresh tokens from XSS. But cookies are subject to CSRF, so protect your refresh endpoint. Two practical defenses: use SameSite=strict or lax where appropriate, and implement a CSRF mitigator for state-changing endpoints (double submit cookie or anti-CSRF header available only to JavaScript after authentication).

If you place refresh tokens in httpOnly cookies, the refresh endpoint must check origin/Referer headers or require a separate CSRF token. For SPAs that need cross-site flows (e.g., third-party embeds), prefer explicit CSRF tokens and careful SameSite choices instead of disabling protections.

  • Set cookie flags: httpOnly, Secure, SameSite=Strict/Lax depending on app flow, and set Path to the refresh endpoint only.
  • Use CSRF protection: double submit cookie or anti-CSRF token—do not rely solely on SameSite.
  • Log refresh attempts and failures (useful for detection of token theft).

5) Invalidation patterns: how to revoke sessions and tokens

There are three common approaches: short access token TTLs + no server state (hard to revoke), blacklist/denylist, and session table with token identifiers. The pragmatic choice is a session table: store refresh token IDs and a 'revoked' flag and a 'lastLogout' timestamp on the user. Use short-lived access tokens so access tokens naturally expire quickly, and use the session table to revoke refresh tokens immediately.

Blacklists for access tokens (storing jti values until expiry) can work but are awkward at scale. A session table keyed by refresh token allows full control: you can expire or revoke refresh tokens server-side, rotate tokens, and invalidate on suspicious events (password change, logout from all devices).

  • Maintain a session table with fields: sessionId, userId, refreshTokenHash, createdAt, lastUsedAt, revokedAt, deviceInfo.
  • Revoke by setting revokedAt; refuse refresh for revoked sessions.
  • On suspicious reuse detection, revoke the compromised session and optionally revoke other sessions for that user.

6) Practical Node.js implementation sketch

Below is a compact flow you can implement using any Node.js stack. Keep access tokens as short-lived signed JWTs. Make refresh tokens opaque random values stored hashed in DB. Rotate refresh tokens and detect reuse.

Pseudocode outline (simplified):

  • On login: generate accessToken = signJWT({sub:userId, exp:now+15m, jti:uuid()})
  • Generate refreshId = randomBase64(64); store hash(refreshId) in DB with session row; set cookie 'refresh' = refreshId; cookie flags: httpOnly Secure SameSite
  • Refresh endpoint: read cookie refreshId; find session by hash; if not found => possible reuse: revoke all sessions or require re-auth; if found => issue new accessToken and new refreshId; update DB with new hashed refreshId and lastUsedAt; set new cookie
  • Logout: mark session revoked; clear cookie
  • Password change or global logout: mark all sessions revoked for user

7) Common pitfalls and how to avoid them

Pitfall: using long-lived access tokens so you never need refresh tokens. Fix: use short-lived access tokens and a refresh scheme. Pitfall: storing refresh in localStorage. Fix: use httpOnly cookies or server-side session store. Pitfall: not rotating tokens. Fix: rotate and log rotations, treat unknown tokens as compromise.

Also, don't confuse stateless validation with availability of control. You can validate a JWT signature without server state but you lose the ability to revoke. Balance statelessness and control by keeping minimal server state about refresh tokens or session versions.

  • Don't rely on token expiry as the only revocation mechanism.
  • Don't expose refresh tokens to JavaScript.
  • Log token lifecycle events (issue, refresh, revoke) for audit and detection.

Conclusion

JWTs are powerful but tutorials often present them as simpler than they need to be. Treat access tokens as short-lived credentials, protect refresh tokens with httpOnly cookies or server-side storage, rotate refresh tokens and detect reuse, and keep a small server-side session representation that enables immediate revocation. These patterns close the gaps that turn implementation shortcuts into security holes.

Action Checklist

  1. Implement a session table for refresh-token metadata in your Node.js app and start issuing opaque refresh tokens instead of embedding long-lived JWTs.
  2. Set access token lifetimes to 5–15 minutes and implement a refresh endpoint that rotates refresh tokens on every use.
  3. Store refresh tokens in httpOnly, Secure cookies with appropriate SameSite settings and add CSRF protection to the refresh endpoint.
  4. Add logging for refresh attempts, rotations, failed validations, and reuse detections; establish an incident response plan for suspected reuse.
  5. Run a threat model focusing on XSS and token theft, then test your refresh and revocation flows with simulated compromised tokens.