Most developers use JWTs few understand how to manage them safely. Here’s the complete guide to doing authentication right.

Introduction: The Misunderstood Token Trio
JWTs are everywhere, powering logins, mobile APIs, microservices, and SPAs.
They promise stateless authentication and scalability, and they work great… until they don’t.
Here’s the problem: most developers stop at “login + token = done.”
But JWTs alone don’t make your app secure. The real challenge lies in how you store, rotate, and expire tokens.
If you’re using JWTs or planning to, this guide explains the right way to handle:
- Access Tokens (short-lived)
- Refresh Tokens (long-lived)
- JWT structure, validation, and revocation
Let’s break it down from first principles with practical code and architecture patterns.
1. What a JWT Really Is (and Why It Exists)
A JWT (JSON Web Token) is a compact, signed string that proves identity or permission.
It consists of three parts:
header.payload.signature
Example:
{
"alg": "HS256",
"typ": "JWT"
}
.
{
"sub": "user_123",
"exp": 1730939200,
"iat": 1730935600,
"scope": "read:users"
}
The signature (using a secret or private key) prevents tampering.
But JWTs are not encrypted by default; anyone can decode and read them.
That’s the first mistake developers make: JWTs aren’t secrets, they’re signed receipts.
2. Access Tokens: Short, Scoped, and Disposable
The Access Token is your user’s pass to the API.
It should:
- Contain just enough info to verify permissions
- Be valid for a short time (10–30 minutes)
- Be stored only in memory, never in persistent storage
Example
const accessToken = jwt.sign(
{ sub: user.id, role: user.role },
process.env.JWT_SECRET,
{ expiresIn: "15m" }
);
When a request hits your API:
- Validate the token’s signature.
- Check claims (
exp,iss,aud). - Ensure the user still exists and has access.
Why short-lived?
If an attacker steals a token, they only have a few minutes of access.
Without a short expiry, a single leak could mean days of exposure.
Rule of thumb:
Access tokens should expire faster than attackers can exploit them.
3. Refresh Tokens: The Gatekeepers of Longevity
If access tokens expire every few minutes, users need a way to stay logged in.
That’s where refresh tokens come in.
A refresh token:
- Is long-lived (days or weeks)
- Never exposed to frontend JavaScript
- Is stored in secure, HTTP-only cookies
- Can request new access tokens from the backend
Example (Node + Express)
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "Strict",
maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});
When the frontend detects an expired access token, it silently calls /auth/refresh the backend verifies the refresh token and issues a new pair.
4. How Refresh Flow Works (Step by Step)
Here’s the complete sequence you should follow.
Step 1: User Logs In
Backend verifies credentials and issues:
- Access token (expires in 15m)
- Refresh token (stored as HTTP-only cookie)
Step 2: Frontend Uses Access Token
All API calls use:
Authorization: Bearer <accessToken>
Step 3: Token Expires
Frontend receives 401 Unauthorized.
Step 4: Silent Refresh
Frontend sends a request to /auth/refresh.
Backend verifies the refresh token and returns new tokens.
Step 5: Rotation
Every refresh operation invalidates the old refresh token and issues a new one.
This prevents reuse attacks if an old refresh token leaks.
5. Why Token Rotation Is Non-Negotiable
Without rotation, a leaked refresh token grants unlimited access.
Attackers can refresh forever, even if users log out.
To prevent this, you must track issued refresh tokens server-side (in a DB or Redis) and invalidate them after use.
Example
if (storedToken.used || storedToken.revoked) {
throw new Error("Refresh token already used");
}
await markTokenUsed(storedToken.id);
issueNewTokens();
Key principle:
Every refresh token should be single-use. Once exchanged, it dies.
This is how modern systems like Auth0, AWS Cognito, and Firebase handle session continuity securely.
6. How (Not) to Store Tokens in the Frontend
The Wrong Way
localStorage.setItem("token", jwt);
This exposes your token to any JavaScript running in the page.
An XSS attack can simply run localStorage.getItem("token") and steal it.
The Right Way
- Keep access tokens in memory (React context, Redux, or a variable).
- Store refresh tokens in HTTP-only cookies.
- Use CSRF tokens if your refresh endpoint accepts cookie-based requests.
This setup ensures that:
- Tokens can’t be read by scripts.
- Tokens expire quickly if stolen.
- Only the server can issue new access tokens.
7. JWT Validation Checklist (Backend Side)
Every API route that accepts JWTs must do these checks:
✅ Verify the signature (HMAC or RSA).
✅ Validate the iss (issuer) and aud (audience) claims.
✅ Ensure exp (expiration) is in the future.
✅ Reject tokens without iat (issued at).
✅ Check the token type access vs refresh.
✅ Optionally: check a revocation list (logout or breach).
Example
import jwt from "jsonwebtoken";
function verifyAccessToken(req, res, next) {
const token = req.headers.authorization?.split(" ")[1];
if (!token) return res.status(401).json({ error: "Missing token" });
try {
const payload = jwt.verify(token, process.env.JWT_SECRET, {
audience: "myapp",
issuer: "auth.myapp.com",
});
req.user = payload;
next();
} catch (err) {
return res.status(401).json({ error: "Invalid or expired token" });
}
}
Never assume a valid signature means a valid session.
Claims matter.
8. Logout and Revocation: The Forgotten Step
“JWTs are stateless, so logout doesn’t exist.”
That’s only half true.
If you don’t revoke tokens on logout, they remain valid until expiration if the user changes their password or deletes their account.
Implementing Logout Safely
- Store refresh tokens server-side with
isRevokedflag. - When the user logs out, mark their refresh token as revoked.
- Block refresh if the token is revoked or reused.
Example:
await db.refreshTokens.update({ id: token.id }, { revoked: true });
Even if the attacker has a valid token, it’ll be rejected on the next refresh.
9. Common JWT Mistakes (and How to Avoid Them)

10. Example: Complete Auth Flow in Express
// login.js
app.post("/auth/login", async (req, res) => {
const user = await authenticate(req.body);
const accessToken = jwt.sign({ sub: user.id }, process.env.JWT_SECRET, { expiresIn: "15m" });
const refreshToken = jwt.sign({ sub: user.id }, process.env.REFRESH_SECRET, { expiresIn: "7d" });
await saveRefreshToken(user.id, refreshToken);
res.cookie("refreshToken", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "Strict",
});
res.json({ accessToken });
});
// refresh.js
app.post("/auth/refresh", async (req, res) => {
const token = req.cookies.refreshToken;
if (!token) return res.status(401).send("Missing refresh token");
const payload = jwt.verify(token, process.env.REFRESH_SECRET);
const stored = await findRefreshToken(payload.sub);
if (!stored || stored.revoked) return res.status(403).send("Invalid refresh token");
await revokeRefreshToken(stored.id);
const newAccess = jwt.sign({ sub: payload.sub }, process.env.JWT_SECRET, { expiresIn: "15m" });
const newRefresh = jwt.sign({ sub: payload.sub }, process.env.REFRESH_SECRET, { expiresIn: "7d" });
await saveRefreshToken(payload.sub, newRefresh);
res.cookie("refreshToken", newRefresh, { httpOnly: true, secure: true, sameSite: "Strict" });
res.json({ accessToken: newAccess });
});
This flow handles:
- Expiration
- Rotation
- Revocation
- Secure storage
Exactly what you want in production.
11. When JWTs Don’t Fit
JWTs aren’t the answer for every app.
If you have:
- Simple, monolithic backends
- Server-rendered apps with minimal scaling
- A few stateless clients
Then classic session-based authentication with cookies and server sessions might be simpler and safer.
JWTs shine when:
- You have multiple services or microservices
- You need stateless scaling
- You’re integrating third-party clients
Use the right tool for the architecture, not just because it’s trendy.
12. Security Layers Beyond Tokens
Good token handling is necessary, but not sufficient. Add these layers too:
- HTTPS only (no plain HTTP).
- Rate limiting on auth endpoints.
- Device fingerprinting or IP-based checks for refresh tokens.
- Secret managers for storing keys (Vault, AWS Secrets Manager).
- Audit logs for token issuance, rotation, and revocation.
Security is about defense in depth; tokens are just one layer.
13. Summary: The Token Commandments
- Access tokens expire fast.
- Refresh tokens are stored securely, not in JS.
- Rotate refresh tokens after every use.
- Validate all JWT claims.
- Revoke tokens on logout or suspicion.
- Never log or expose tokens in URLs.
- Use HTTPS, always.
Follow these, and your JWT-based authentication will be resilient, scalable, and hard to abuse.
Conclusion: Tokens Aren’t the Problem, Design Is
JWTs didn’t cause breaches. Bad implementations did.
The right setup makes your system both stateless and safe, where users stay logged in, tokens refresh seamlessly, and leaks have minimal impact.
When in doubt, remember:
“A good authentication system isn’t one that works perfectly it’s one that fails safely.”
Design yours that way.
Call to Action
How does your current token setup compare?
Do you rotate refresh tokens or still rely on static ones?
Share your setup or lessons in the comments; it might help someone harden theirs.
And if this guide clarified how tokens really work, bookmark it for your next project.


Leave a Reply