Authentication done right: JWT, sessions, and OAuth explained — Like a Marvel superhero assembling the team
The Quest Begins (The "Why") I still remember the first time I tried to add login to a side‑project. I’d read a tutorial that said “just store a token in localStorage and you’re good,” slapped together a few fetch calls, and called it a day. A week later I got an email from a user: “Hey, I can’t log out, and someone else seems to be using my account.” My heart sank. I realized I’d bolted a flashy lock onto a screen door — it looked secure, but anyone with a screwdriver could walk right in. That moment kicked off a deep dive. I wanted to understand the trade‑offs between sessions, JSON Web Tokens (JWT), and OAuth so I could pick the right tool for each job, not just the shiniest one. What followed felt like assembling a superhero squad: each member has a unique power, and knowing when to call on them makes the difference between saving the day and causing collateral damage. The Revelation (The Insight) Sessions – The Trusty Sidekick Sessions are the classic, server‑side approach. When a user logs in, the server creates a random identifier (the session ID), stores it in a database or cache (Redis, Memcached, etc.), and sends it back to the browser as an HttpOnly cookie. On every request, the browser automatically includes that cookie, the server looks up the ID, and pulls the associated user data. Why I love it: The secret never leaves the server, so stealing a cookie only gives an attacker a session ID that’s useless without the server’s store. Revoking a session is trivial — just delete the row from the store. Works great for traditional web apps where you control both front‑ and back‑end. Where it stumbles: Horizontal scaling requires a shared session store; otherwise each instance forgets who the user is. Every request does a database/lookup, which can add latency if the store isn’t fast enough. JWT – The Lone Wolf with a Signed Badge A JWT is a compact, URL‑safe string that contains claims (like sub, exp, roles) and is cryptographically signed (HMAC or RSA). The server creates it after verifying credentials, sends it to the client (usually in an Authorization header or a cookie), and the client sends it back on each request. The server verifies the signature and trusts the payload — no lookup needed. Why it’s tempting: Stateless! No server‑side storage means you can scale out effortlessly. Perfect for APIs that serve mobile apps, SPAs, or micro‑services where you don’t want a shared session store. You can embed roles or permissions directly in the token, reducing extra DB calls. Where it bites: Because the token lives on the client, you can’t instantly revoke it. If a token is stolen, it’s valid until it expires (unless you implement a blacklist, which re‑introduces state). The payload is base64‑encoded, not encrypted — anyone can read it. Never put secrets in a JWT. Size: each request carries the whole token; bulky tokens add overhead. OAuth – The Diplomatic Envoy OAuth 2.0 isn’t an authentication protocol by itself; it’s an authorization framework. Think of it as a valet key: you give a third‑party app a limited‑scope token that lets it act on your behalf without sharing your password. OpenID Connect (OIDC) builds on OAuth to add identity information, giving you a reliable way to log users in via Google, GitHub, Auth0, etc. Why it shines: Users never share passwords with your app; they authenticate with a trusted provider. Delegated access — you can request only the permissions you need (read email, post photos, etc.). Great for SSO and letting users sign in with existing social accounts. Where it gets tricky: The flow involves several redirects (authorization code grant with PKCE is the safest for SPAs). You must securely store the client secret (if you have one) and validate state/redirect URIs to prevent CSRF. Token lifetime and refresh handling add complexity; you often end up combining OAuth with JWTs or sessions for the actual API calls. Wielding the Power (Code & Examples) Below are three patterns I use daily. Each snippet shows a common pitfall (the trap) and the fixed version (the victory). Feel free to copy‑paste, adapt, and experiment. 1. Session‑Based Login (Express + Redis) Trap: Storing the session ID in a regular cookie (accessible via JavaScript) → XSS can steal it. // ❌ Bad: cookie accessible to document.cookie app.use(session({ store: new RedisStore({ client: redisClient }), secret: process.env.SESSION_SECRET, resave: false, saveUninitialized: false, cookie: { httpOnly: false, secure: process.env.NODE_ENV === 'production' } // { const auth = req.headers.authorization; if (!auth?.startsWith('Bearer ')) return res.status(401).send('Missing token'); const token = auth.slice(7); jwt.verify(token, process.env.JWT_SECRET, (err, payload) => { if (err) return res.status(401).send('Invalid token'); req.user = payload; // { sub, role, exp } next(); }); }); If you need the user’s name or picture, fetch it from the DB using payload.sub. The token stays small and safe. 3. OAuth 2.0 Authorization Code Flow with PKCE (React + Express) Trap: Using the implicit flow (returning token in the URL fragment) for a SPA → token exposed in browser history and referrer headers. // ❌ Bad: implicit flow (simplified) const authUrl = `https://github.com/login/oauth/authorize? client_id=${CLIENT_ID}& redirect_uri=${encodeURIComponent(REDIRECT_URI)}& scope=user:email& response_type=token`; // ← token comes back in URL fragment Victory: Use the authorization code grant with PKCE, which never exposes the token in the URL. Step 1 – Generate code verifier & challenge (client): // utils/pkce.js import crypto from 'crypto'; export function base64urlEncode(buffer) { return buffer.toString('base64') .replace(/\+/g, '-') .replace(/\//g, '_') .replace(/=+$/, ''); } export function generatePkcePair() { const verifier = base64urlEncode(crypto.randomBytes(32)); const challenge = base64urlEncode( crypto.createHash('sha256').update(verifier).digest() ); return { verifier, challenge }; } Step 2 – Redirect to GitHub: // In your React component import { generatePkcePair, base64urlEncode } from './utils/pkce'; function handleLogin() { const { verifier, challenge } = generatePkcePair(); localStorage.setItem('pkce_verifier', verifier); // keep for later const authUrl = new URL('https://github.com/login/oauth/authorize'); authUrl.searchParams.set('client_id', CLIENT_ID); authUrl.searchParams.set('redirect_uri', REDIRECT_URI); authUrl.searchParams.set('response_type', 'code'); authUrl.searchParams.set('scope', 'read:user user:email'); authUrl.searchParams.set('code_challenge', challenge); authUrl.searchParams.set('code_challenge_method', 'S256'); // state helps prevent CSRF – generate a random string and store it similarly authUrl.searchParams.set('state', crypto.randomBytes(16).toString('hex')); window.location.href = authUrl.toString(); } Step 3 – Exchange code for token (server): // Express callback endpoint app.get('/auth/github/callback', async (req, res) => { const { code, state } = req.query; // validate state against stored value (omitted for brevity) const verifier = localStorage.getItem('pkce_verifier'); // in real app, retrieve from session or cookie const tokenResponse = await fetch('https://github.com/login/oauth/access_token', { method: 'POST', headers: { 'Accept': 'application/json', 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ client_id: CLIENT_ID, client_secret: CLIENT_SECRET, // only if you have a confidential client code, redirect_uri: REDIRECT_URI, code_verifier: verifier }) }); const data = await tokenResponse.json(); // data.access_token is now yours to use for API calls on behalf of the user res.json({ access_token: data.access_token }); }); Notice how the access token never touches the URL — it’s exchanged server‑side after the user grants permission. This mitigates token leakage via logs, referrers, or browser history. Why This New Power Matters Armed with these patterns, you can: Pick the right tool: Sessions for classic server‑rendered apps where instant logout matters; JWTs for stateless APIs that need to scale across containers; OAuth/OIDC when you want to delegate login to trusted providers and keep passwords out of your hands. Avoid the classic traps: No more storing secrets in tokens, no more XSS‑stealable cookies, no more implicit flow leaks. Build with confidence: When a user logs out, you can delete the session row or blacklist the JWT (if you adopt a short‑lived token + refresh pattern). When a third‑party integration asks for scopes, you can request only what you truly need, respecting the principle of least privilege. Every time I refactor an auth module using these ideas, I feel like I’ve swapped a rusty lock for a biometric vault — still simple to use, but far harder to break. Your Turn: A Mini Quest Take a small project you’ve got lying around — maybe a Todo API or a personal blog. Right now it probably uses a quick‑and‑dirty JWT stored in localStorage. Try swapping it out for a session‑based approach with an HttpOnly cookie, or add GitHub login via OAuth with PKCE. Notice how the flow changes, how the security posture improves, and how the code feels more intentional. When you’ve got it working, drop a comment or tweet me your experience — what surprised you? What still feels tricky? Let’s keep leveling up our auth game together! 🚀
This is a summary aggregated from Dev.to. Read the complete article on the original site:
Read full article at Dev.to