, ,

The Right Way to Handle JWTs, Access Tokens, and Refresh Tokens

Posted by

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

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:

  1. Validate the token’s signature.
  2. Check claims (exp, iss, aud).
  3. 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

  1. Store refresh tokens server-side with isRevoked flag.
  2. When the user logs out, mark their refresh token as revoked.
  3. 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

  1. Access tokens expire fast.
  2. Refresh tokens are stored securely, not in JS.
  3. Rotate refresh tokens after every use.
  4. Validate all JWT claims.
  5. Revoke tokens on logout or suspicion.
  6. Never log or expose tokens in URLs.
  7. 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

Your email address will not be published. Required fields are marked *