The Quest Begins (The "Why")
I still remember the first time I tried to add login to a side‑project. I was excited, pumped full of coffee, and thought “I’ll just toss a JWT into the header and call it a day.” Spoiler: I ended up debugging token expiry at 2 a.m., wondering why my API kept returning 401s even though I swore I’d set the secret correctly. The frustration felt like fighting a boss that kept respawning with a new attack pattern.
That night I realized authentication isn’t a one‑size‑fits‑all spell. There are three main schools of thought — sessions, JWTs, and OAuth — each with its own strengths, pitfalls, and ideal battlegrounds. If you pick the wrong one, you’ll spend more time patching holes than building features. Let’s embark on a quest to demystify them, so you can choose the right weapon for your next adventure.
The Revelation (The Insight)
Sessions – The Trusty Shield
Think of a session as a classic castle guard. The server creates a session ID, stores it (usually in Redis or a DB), and sends that ID back to the browser as a cookie. On every request, the browser hands the cookie over; the server looks up the ID and says, “Ah, I know you.”
Pros:
- Server‑side control → you can invalidate a session instantly (log out everywhere).
- No need to worry about token size or theft beyond the cookie (which can be HttpOnly, Secure, SameSite).
Cons:
- Requires a store; scaling means you need a shared session store.
- Slightly more latency on each request (lookup).
JWT – The Agile Blade
A JSON Web Token is a self‑contained piece of data: header.payload.signature. The payload holds claims (user ID, roles, expiry). Because it’s signed, anyone can verify it without hitting a DB — great for stateless APIs.
Pros:
- Truly stateless; perfect for microservices or serverless functions.
- Compact; can be sent in an Authorization header.
Cons:
- Once issued, you can’t revoke it without a blacklist (which brings state back).
- Token size grows with payload; be careful not to overload headers.
OAuth – The Diplomacy Quest
OAuth 2.0 isn’t about authenticating your users directly; it’s about letting them prove who they are via a trusted third party (Google, GitHub, etc.). You get an access token that you can use to call APIs on behalf of the user.
Pros:
- Users don’t need to create another password; they use existing identities.
- Scopes let you request only the permissions you need.
Cons:
- Adds complexity (redirects, state handling, token exchange).
- You must trust the provider and handle token refresh correctly.
The “aha!” moment for me was realizing that you don’t have to pick just one — you can combine them. For a classic web app, use sessions for the UI and JWTs for your internal APIs. For a mobile app that needs to talk to multiple services, OAuth + JWT works beautifully.
Wielding the Power (Code & Examples)
The Struggle: Hand‑rolled JWT Mess
// ❌ Bad practice: secret hard‑coded, no expiry check, token sent in URL
const jwt = require('jsonwebtoken');
const SECRET = 'mySuperSecret'; // never do this!
app.get('/login', (req, res) => {
const token = jwt.sign({ userId: req.body.username }, SECRET);
// sending token as query param – visible in logs, history, referrer
res.redirect(`/dashboard?token=${token}`);
});
app.get('/data', (req, res) => {
const token = req.query.token;
try {
const payload = jwt.verify(token, SECRET);
res.json({ msg: `Hello ${payload.userId}` });
} catch (e) {
res.status(401).send('Invalid token');
}
});
What went wrong?
- Secret in source control.
- Token exposed in URLs (logs, browser history).
- No expiry → token lives forever.
- No HTTPS enforcement (if you forget, it’s sent in plain text).
The Victory: Proper JWT Setup
// ✅ Good practice: env‑based secret, short expiry, HttpOnly cookie for refresh
require('dotenv').config();
const jwt = require('jsonwebtoken');
const express = require('express');
const app = express();
const ACCESS_TOKEN_SECRET = process.env.ACCESS_TOKEN_SECRET; // 256‑bit random
const REFRESH_TOKEN_SECRET = process.env.REFRESH_TOKEN_SECRET;
// Middleware to verify access token
function authenticateAccessToken(req, res, next) {
const authHeader = req.headers['authorization'];
const token = authHeader && authHeader.split(' ')[1]; // Bearer <token>
if (!token) return res.sendStatus(401);
jwt.verify(token, ACCESS_TOKEN_SECRET, (err, user) => {
if (err) return res.sendStatus(403);
req.user = user;
next();
});
}
// Login route – issue both tokens
app.post('/login', (req, res) => {
const { username, password } = req.body;
// … validate credentials against DB …
const accessToken = jwt.sign(
{ userId: username, role: 'user' },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' } // short-lived
);
const refreshToken = jwt.sign(
{ userId: username },
REFRESH_TOKEN_SECRET,
{ expiresIn: '7d' } // long‑lived, stored HttpOnly
);
// Set refresh token as HttpOnly, Secure, SameSite cookie
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'strict',
maxAge: 7 * 24 * 60 * 60 * 1000,
});
res.json({ accessToken });
});
// Refresh endpoint – exchange old refresh token for new access token
app.post('/token', (req, res) => {
const refreshToken = req.cookies.refreshToken;
if (!refreshToken) return res.sendStatus(401);
jwt.verify(refreshToken, REFRESH_TOKEN_SECRET, (err, user) => {
if (err) return res.sendStatus(403);
const newAccessToken = jwt.sign(
{ userId: user.userId, role: user.role },
ACCESS_TOKEN_SECRET,
{ expiresIn: '15m' }
);
res.json({ accessToken: newAccessToken });
});
});
// Protected route
app.get('/profile', authenticateAccessToken, (req, res) => {
res.json({ msg: `Hello ${req.user.userId}!`, role: req.user.role });
});
Why this feels victorious
- Secrets live in environment variables, not source.
- Access token is short‑lived and carried in the Authorization header (no URL leakage).
- Refresh token is stored safely in an HttpOnly cookie, mitigating XSS theft.
- On refresh we issue a brand‑new access token, keeping the window of compromise tiny.
OAuth‑Lite: “Login with GitHub”
const express = require('express');
const passport = require('passport');
const GitHubStrategy = require('passport-github2').Strategy;
const session = require('express-session');
const app = express();
app.use(session({ secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false }));
app.use(passport.initialize());
app.use(passport.session());
passport.use(new GitHubStrategy({
clientID: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
callbackURL: '/auth/github/callback'
},
(accessToken, refreshToken, profile, done) => {
// Find or create user in DB using profile.id
return done(null, { id: profile.id, username: profile.username });
}));
passport.serializeUser((user, done) => done(null, user));
passport.deserializeUser((obj, done) => done(null, app));
app.get('/auth/github',
passport.authenticate('github', { scope: ['user:email'] })
);
app.get('/auth/github/callback',
passport.authenticate('github', { failureRedirect: '/login' }),
(req, res) => {
// Successful auth – create a session cookie for the UI
req.session.user = req.user;
res.redirect('/dashboard');
}
);
// Protected UI route
app.get('/dashboard', (req, res) => {
if (!req.session.user) return res.redirect('/login');
res.send(`<h1>Welcome, ${req.session.user.username}!</h1>`);
});
Takeaway: OAuth hands off the heavy lifting to GitHub. You only need to verify the callback, create a session (or JWT) for your own app, and let the user enjoy a password‑less login.
Why This New Power Matters
Now you’ve got three reliable patterns in your toolbox:
- Sessions for classic server‑rendered apps where you want instant logout and simple cookie handling.
- JWTs for stateless APIs, micro‑services, or any scenario where you don’t want to hit a store on every request.
- OAuth when you want to delegate identity to a trusted provider and keep password management out of your codebase.
Mix and match! A typical SaaS product might serve its React front‑end via session cookies, while its internal micro‑services chat using short‑lived JWTs, and let users sign‑in with Google or GitHub through OAuth.
When you stop treating authentication as a mystical black box and start seeing it as a set of interchangeable tools, you’ll spend less time firefighting token bugs and more time shipping features that delight users.
Your Turn – The Next Quest
Here’s a challenge: take a small Express API you have lying around, add the access/refresh token flow shown above, and then protect a route with the authenticateAccessToken middleware. Once that’s working, try swapping in a GitHub OAuth login for the UI and observe how the session cookie emerges.
Got stuck? Drop a comment below—I love hearing about the bugs you squash and the victories you earn. Now go forth, and may your tokens always be valid! 🚀
Top comments (0)