The Quest Begins (The "Why")
Honestly, I still remember the first time I tried to add authentication to a side‑project. I was building a tiny note‑taking app, excited to let users sign in with Google, and I thought, “How hard can it be?” I slapped together a JWT, shoved it into localStorage, and called it a day. The login worked, but a few weeks later I started seeing strange things: tokens that never expired, users able to tweak the payload and gain admin rights, and a creepy feeling that I’d left the back door wide open.
It felt like I was wandering through a dungeon with a flickering torch, hearing growls in the dark but never seeing the monster. I knew I needed a real map—something that explained why JWTs are useful, when sessions shine, and how OAuth fits into the picture without turning my code into a spaghetti nightmare. So I embarked on a quest to understand authentication done right, and I’m here to share the treasure I found.
The Revelation (The Insight)
Here’s the thing: authentication isn’t a one‑size‑fits‑all spell. It’s more like choosing the right weapon for the job.
- JWTs (JSON Web Tokens) are great for stateless APIs. They carry claims (who you are, what you can do) signed with a secret or a private key. The magic is that the server doesn’t need to look anything up—just verify the signature and trust the payload.
- Server‑side sessions shine when you need instant revocation or want to keep sensitive data off the client. A random session ID lives in a cookie (HTTP‑Only, Secure) and points to server‑side storage (Redis, a DB, etc.). Logout is as simple as deleting that row.
- OAuth 2.0 (often with OpenID Connect) is the delegated‑access protocol. It lets users log in via Google, GitHub, or any provider without you ever seeing their password. You get an access token (often a JWT) that you can use to call APIs on the user’s behalf.
The insight that blew my mind? You can combine them. Use OAuth to obtain a JWT, then store that JWT in an HTTP‑Only cookie (or send it as a Bearer header) and treat it like a session token for your own API. You get the convenience of stateless verification and the ability to revoke by blacklisting the token’s jti (JWT ID) in Redis.
Wielding the Power (Code & Examples)
The Struggle: A Naïve JWT Approach
// naive-auth.js (don’t do this!)
const jwt = require('jsonwebtoken');
const SECRET = 'super-secret-key'; // 🙈 hard‑coded, never rotated
function login(req, res) {
const { username, password } = req.body;
// pretend we checked the DB …
const payload = { sub: username, role: 'user' };
const token = jwt.sign(payload, SECRET, { expiresIn: '1h' });
// 👉 sending token to the client via JSON → vulnerable to XSS
res.json({ token });
}
// later, anywhere in the app …
function protect(req, res, next) {
const auth = req.headers.authorization;
if (!auth?.startsWith('Bearer ')) return res.sendStatus(401);
const token = auth.slice(7);
try {
const payload = jwt.verify(token, SECRET);
req.user = payload; // 🎉 we trust it!
next();
} catch (e) {
return res.sendStatus(401);
}
}
What went wrong?
- Hard‑coded secret – if your repo leaks, every token is forgeable.
-
Token stored in
localStorage/JSON response – exposed to XSS. - No refresh mechanism – users get logged out after an hour with no way to renew silently.
- No revocation list – a stolen token is valid until it expires.
The Victory: A Robust Hybrid (OAuth → JWT → HTTP‑Only Cookie)
Below is a compact Express example that shows the “right” way. I’ll point out the traps as we go.
// auth-server.js
require('dotenv').config();
const express = require('express');
const jwt = require('jsonwebtoken');
const redis = require('redis'); // for revocation list & refresh storage
const { OAuth2Client } = require('google-auth-library');
const app = express();
app.use(express.json());
const redisClient = redis.createClient({ url: process.env.REDIS_URL });
redisClient.connect().catch(console.error);
const GOOGLE_CLIENT_ID = process.env.GOOGLE_CLIENT_ID;
const jwtSigningKey = process.env.JWT_SIGNING_KEY; // load from env, rotate regularly
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
// ---------- 1️⃣ OAuth Google Login (step 1) ----------
app.get('/auth/google', (req, res) => {
const oAuth2Client = new OAuth2Client(GOOGLE_CLIENT_ID);
const authUrl = oAuth2Client.generateAuthUrl({
access_type: 'offline',
scope: ['profile', 'email', 'openid'],
redirect_uri: `${process.env.BASE_URL}/auth/google/callback`,
});
res.redirect(authUrl);
});
// ---------- 2️⃣ OAuth Callback (step 2) ----------
app.get('/auth/google/callback', async (req, res) => {
const { code } = req.query;
const oAuth2Client = new OAuth2Client(
GOOGLE_CLIENT_ID,
process.env.GOOGLE_CLIENT_SECRET,
`${process.env.BASE_URL}/auth/google/callback`
);
const { tokens } = await oAuth2Client.getToken(code);
oAuth2Client.setCredentials(tokens);
// Fetch user info from Google
const ticket = await oAuth2Client.verifyIdToken({
idToken: tokens.id_token,
audience: GOOGLE_CLIENT_ID,
});
const payload = ticket.getPayload(); // { sub, email, name, picture, … }
// ---------- 3️⃣ Create our own JWT (access token) ----------
const accessToken = jwt.sign(
{ sub: payload.sub, email: payload.email, role: 'user' },
jwtSigningKey,
{ expiresIn: '15m' } // short‑lived
);
// ---------- 4️⃣ Create a refresh token (opaque, stored in Redis) ----------
const refreshToken = jwt.sign({ sub: payload.sub }, REFRESH_TOKEN_SECRET, {
expiresIn: '30d',
});
// Store mapping so we can revoke later
await redisClient.set(
`refresh:${payload.sub}`,
refreshToken,
'EX',
60 * 60 * 24 * 30 // 30 days
);
// ---------- 5️⃣ Send both tokens via HTTP‑Only cookies ----------
res
.cookie('access_token', accessToken, {
httpOnly: true,
secure: true, // HTTPS only in prod
sameSite: 'strict',
maxAge: 15 * 60 * 1000, // 15 min
})
.cookie('refresh_token', refreshToken, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 30 * 24 * 60 * 60 * 1000, // 30 days
})
.redirect('/'); // send user to frontend
});
// ---------- 6️⃣ Access‑Token Middleware ----------
app.use((req, res, next) => {
const token = req.cookies.access_token;
if (!token) return res.sendStatus(401);
try {
const payload = jwt.verify(token, jwtSigningKey);
// Optional: check if token is blacklisted (e.g., after logout)
redisClient.get(`blacklist:${token}`).then((bl) => {
if (bl) return res.sendStatus(401);
req.user = payload;
next();
});
} catch (err) {
// If expired, try to refresh
if (err.name === 'TokenExpiredError') {
return refreshAccessToken(req, res, next);
}
return res.sendStatus(401);
}
});
// ---------- 7️⃣ Refresh Endpoint ----------
async function refreshAccessToken(req, res, next) {
const oldRefresh = req.cookies.refresh_token;
if (!oldRefresh) return res.sendStatus(401);
try {
const payload = jwt.verify(oldRefresh, REFRESH_TOKEN_SECRET);
// Verify the refresh token still exists in Redis
const stored = await redisClient.get(`refresh:${payload.sub}`);
if (stored !== oldRefresh) throw new Error('Invalid refresh');
// Issue new access token
const newAccess = jwt.sign(
{ sub: payload.sub, email: payload.email, role: 'user' },
jwtSigningKey,
{ expiresIn: '15m' }
);
res
.cookie('access_token', newAccess, {
httpOnly: true,
secure: true,
sameSite: 'strict',
maxAge: 15 * 60 * 1000,
})
.sendStatus(200);
} catch (e) {
// On any failure, clear cookies and force re‑login
res.clearCookie('access_token').clearCookie('refresh_token').sendStatus(401);
}
}
// ---------- 8️⃣ Logout (revoke both) ----------
app.post('/logout', (req, res) => {
const access = req.cookies.access_token;
const refresh = req.cookies.refresh_token;
if (access) redisClient.set(`blacklist:${access}`, '1', 'EX', 60 * 60); // 1 h
if (refresh) {
const payload = jwt.verify(refresh, REFRESH_TOKEN_SECRET);
redisClient.del(`refresh:${payload.sub}`);
}
res.clearCookie('access_token').clearCookie('refresh_token').sendStatus(200);
});
app.listen(3000, () => console.log('Auth server listening on :3000'));
Why this feels like a win:
- Short‑lived access JWT (15 min) limits the damage if stolen.
- HTTP‑Only cookies keep the token out of JavaScript’s reach → mitigates XSS.
- Refresh token stored server‑side (Redis) lets us invalidate it on logout or suspicious activity.
- Blacklist table gives us immediate revocation for access tokens without waiting for expiry.
- OAuth flow means we never handle raw passwords; we rely on Google’s secure authentication.
Common Traps (the “monsters” to avoid)
| Trap | What happens | How to dodge |
|---|---|---|
Storing JWT in localStorage |
XSS can steal it → account takeover. | Use HTTP‑Only cookies; if you must use localStorage, mitigate with strict CSP and short expiry. |
| Hard‑coding secrets | Leak in repo → anyone can forge tokens. | Keep secrets in environment variables or a secret manager; rotate them regularly. |
Skipping token validation (e.g., not checking exp or aud) |
Accepts any token, even expired or from another audience. | Always use a library’s verify with a complete options object (issuer, audience, clockTolerance). |
| Never rotating keys | Old compromised key stays valid forever. | Use a key ID (kid) in JWT header and maintain a JWKS endpoint; rotate periodically. |
| ** forgetting to invalidate refresh tokens** | Stolen refresh token yields endless new access tokens. | Store refresh tokens in a revocable store (Redis, DB) and delete on logout or password change. |
Why This New Power Matters
Now you can build APIs that feel lightweight (no session lookups on every request) yet secure (short‑lived tokens, revocation, and HTTP‑Only storage). You get the best of both worlds: the scalability of stateless JWTs and the control of server‑side sessions when you need it.
Imagine letting users sign in with GitHub, Google, or even a classic email/password flow, all while your backend stays clean and auditable. You can issue fine‑grained
Top comments (0)