If you’ve ever logged into an app, accessed an API, or built a backend, you’ve already used authentication. But do you really understand how it works under the hood? or know when OAuth beats API Keys, or why a JWT isn’t a session?
Let’s change that today.
This is not another boring checklist of auth methods. I’m going to walk through every major authentication method used in modern APIs and web applications, not in a textbook way, but the way a senior engineer would explain it to you over coffee.
By the end, you’ll know exactly what’s happening under the hood, how each method fits into the ecosystem, and critically, when to use what.
🔐 Why Authentication Matters More Than You Think
Every API call answers one fundamental question:
“Who are you, and why should I trust you?”
Authentication is how systems answer that question.
But here’s the twist:
👉 Authentication is not just about logging in.
👉 It’s about trust, identity, and access control at scale.
And as systems evolved, so did authentication.
Let’s walk through that evolution step by step.
1. Basic Authentication: The Grandparent of All Auth
🧠 How it works
Basic Auth is exactly what it sounds like:
Client sends: take your username and password, smash them together with a colon (
username:password), Base64-encode the whole thing, and slap it in an HTTP header.
Authorization: Basic base64(username:password)
// Example
Authorization: Basic dXNlcjpwYXNzd29yZA==Server decodes and validates credentials.
⚙️ Example
GET /api/resource HTTP/1.1
Host: api.example.com
Authorization: Basic dXNlcjpwYXNzd29yZA==🚨 Problems
Credentials are sent on every request
Easily decodable (not encrypted)
Requires HTTPS to be even remotely safe
No session control
💡Where it still makes sense:
Internal tooling and admin panels over HTTPS
Simple server-to-server communication in trusted environments
Quick prototyping (please, not in production)
Some legacy systems and IoT devices where simplicity wins
Basic Auth is simple, but simplicity comes at a cost.
The real problem with Basic Auth isn’t just security, it’s that the server has to validate credentials on every single request. That’s a database hit every time. At scale, it becomes a bottleneck. That’s what drove the evolution to sessions.
2. Sessions: Stateful Auth and the Cookie Monster
Sessions solved Basic Auth’s “validate every request” problem by introducing state. The idea is elegant: prove who you are once, and the server will remember you.
Here’s how the flow works:
You send your username and password (once)
The server validates them and creates a session — a record in its database with a unique session ID and your user info
The server sends that session ID back to your browser in a cookie
Every subsequent request, your browser automatically sends that cookie
The server looks up the session ID, finds your record, and knows who you are
POST /login
Body: { username: "alice", password: "secret" }
Response:
Set-Cookie: sessionId=abc123xyz; HttpOnly; Secure; SameSite=StrictThe magic word here is HttpOnly. It tells the browser: "don't let JavaScript touch this cookie." That's your defence against XSS attacks stealing the session.
Sessions are stateful: The server holds all the data. This is their strength and their weakness.
👍 Pros
Secure (credentials not sent repeatedly)
You can invalidate a session instantly.
User gets compromised? Delete the session record.
👎 Cons
Stateful → hard to scale in microservices
Requires sessions in centralized session store like Redis.
Doesn’t work well for APIs consumed by mobile/third-party clients
// Express.js session example
app.use(session({
secret: process.env.SESSION_SECRET,
resave: false,
saveUninitialized: false,
store: new RedisStore({ client: redisClient }),
cookie: {
httpOnly: true,
secure: true, // HTTPS only
sameSite: 'strict',
maxAge: 1000 * 60 * 60 * 24 // 24 hours
}
}));Sessions work great for monoliths or admin dashboards, but struggle in distributed systems, mobile apps.
3. API Keys: The Workhorse of Developer APIs
Every developer has used an API key. You sign up for Stripe, you get a key like sk_live_abc123.... You put it in a header. Things work. Simple.
API Keys are essentially permanent passwords for machines, not humans. They’re designed for server-to-server communication — your backend calling Stripe’s backend.
GET /v1/charges HTTP/1.1
Host: api.stripe.com
Authorization: Bearer sk_live_abc123def456Or sometimes in a custom header:
X-API-Key: your_api_key_hereOr as a query parameter (please don’t — it shows up in logs):
GET /api/data?api_key=abc123 ← please noUnder the hood, the server maintains a mapping of API keys to accounts. When a request comes in, it looks up the key, finds the associated account, checks permissions, and proceeds. No passwords, no sessions, no token expiration by default.
The killer features of API Keys:
Scoping: Give read-only keys to read-only apps, write keys to write apps
Rotation: Compromised? Generate a new one, revoke the old one — no user involved
Rate limiting: Attach rate limits per key, not per IP (much harder to spoof)
Analytics: Track usage per key, per client, per endpoint
// Generating a secure API key
const crypto = require('crypto');
function generateApiKey() {
return `sk_${crypto.randomBytes(32).toString('hex')}`;
}
// Storing it — NEVER store the raw key
const { hash, salt } = hashApiKey(rawKey);
await db.apiKeys.create({ hash, salt, userId, scopes });👍 Pros
Simple to implement
Ideal for server-to-server communication
👎 Cons
No user identity (only identifies the application)
No built-in expiration or scope
Easily leaked if not handled properly
💡 Real-world use
👉 Public APIs (Google Maps, Stripe, etc.)
👉 Internal microservices
API Keys identify apps, not users.
4. Bearer Tokens (The Stateless Shift)
This is where many developers get confused, so let’s be precise: Bearer Token is a header format, not an authentication mechanism.
When you see:
Authorization: Bearer eyJhbGciOiJIUzI1NiJ9...The “Bearer” just means “whoever bears this token can use it.” It’s the HTTP standard way to pass a token. The token itself could be:
A random string (like an API key)
An opaque token from an OAuth server
A JWT (more on that next)
Literally anything the server understands
The pattern comes from RFC 6750, which standardized how OAuth 2.0 tokens should be sent. The name means: possession of the token is sufficient proof of authorization. No signature required from the bearer — just present the token.
🧠 How it works
Client sends:
Authorization: Bearer <token>Server:
Validates token
Grants or denies access
🔑 Key Idea
Whoever “bears” the valid token → gets access
👍 Pros
Stateless (no server-side storage)
Scales beautifully
👎 Cons
If leaked → immediate access
Needs expiration handling
Bearer tokens are the foundation of modern API security.
5. JWT (JSON Web Tokens): Self-Contained, Stateless
JWT is not an authentication protocol. JWT is a token format. But it’s a format so clever that it fundamentally changed how APIs handle auth.
The big idea: what if the token itself contained all the user information, cryptographically signed, so the server doesn’t need to look anything up?
A JWT looks like this:
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJ1c2VyXzEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTcwMDAwMDAwMH0.SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5cThree Base64-encoded parts, separated by dots:
Part 1 Header:
{
"alg": "HS256",
"typ": "JWT"
}Part 2 Payload (the claims):
{
"sub": "user_123",
"role": "admin",
"email": "[email protected]",
"iat": 1700000000,
"exp": 1700003600
}Part 3 Signature:
HMACSHA256(
base64(header) + "." + base64(payload),
secret_key
)🔐 How it works
Server signs token with secret/private key
Client stores token (usually localStorage/cookie)
Client sends a token with each request
Server verifies signature, no DB needed
const jwt = require('jsonwebtoken');
// Issue a token on login
const token = jwt.sign(
{ sub: user.id, role: user.role, email: user.email },
process.env.JWT_SECRET,
{ expiresIn: '1h' }
);
// Verify on each request
const middleware = (req, res, next) => {
try {
const token = req.headers.authorization?.split(' ')[1];
const decoded = jwt.verify(token, process.env.JWT_SECRET);
req.user = decoded;
next();
} catch {
res.status(401).json({ error: 'Invalid token' });
}
};👍 Pros
Stateless (perfect for microservices)
Contains user data (claims)
No need to query the DB on every request
👎 Cons
Hard to revoke (until expiration)
Large token size
Security risks if stored improperly
💡 Best Practices
Use short expiration times
Store in HTTP-only cookies
Avoid sensitive data in the payload
The standard solution: short-lived access tokens (15 minutes) paired with long-lived refresh tokens (stored securely, used to get new access tokens). The refresh token lives in the database, so you can revoke it.
[Login] → access_token (15min) + refresh_token (7days)
[API call] → Authorization: Bearer <access_token>
[Token expired] → POST /refresh { refresh_token } → new access_token
[Logout] → DELETE refresh_token from DBJWT is powerful, but dangerous if misused.
JWT pitfalls to avoid:
alg: noneNever accept unsigned tokensSensitive data in payload — it’s Base64-encoded, not encrypted; anyone can decode it
Long expiry times defeat the purpose
Storing in localStorage — vulnerable to XSS; prefer
HttpOnlycookies
💡 Enjoying this article?
Every week day, I publish practical, production-ready deep dives covering Web development, System Design, Open source projects, Tech industry trends and AI Engineering and tools.
🧠 What problem does it solve?
“How can I let an app access my data without giving my password?”
Example:
👉 “Login with Google”
👉 “Connect GitHub to your app”
OAuth 2.0 is the most powerful and most misunderstood protocol in this entire stack. Here’s the thing that will immediately clarify everything:
OAuth 2.0 is NOT about authentication. It’s about authorization.
It answers this question: “Can Application B access User A’s data on Service C — without User A giving Application B their Service C password?”
Think “Login with Google.” You’re not giving your Google password to that third-party app. You’re giving the app permission to access specific parts of your Google account. That’s OAuth.
The Four Key Players
Resource Owner → The user (you)
Client → The app wanting access (third-party app)
Authorization Server → The trust broker (Google's auth server)
Resource Server → The API holding the data (Google's APIs)1. User clicks "Login with Google" in your app
2. App redirects to Google's auth server:
GET https://accounts.google.com/o/oauth2/auth
?client_id=your_app_id
&redirect_uri=https://yourapp.com/callback
&response_type=code
&scope=email profile
&state=random_csrf_token
3. User logs into Google and grants permission
4. Google redirects back with an authorization code:
https://yourapp.com/callback?code=4/abc123&state=random_csrf_token
5. Your server exchanges the code for tokens (server-to-server):
POST https://oauth2.googleapis.com/token
{ client_id, client_secret, code, redirect_uri }
6. Google returns:
{ access_token, refresh_token, expires_in }
7. Your server uses access_token to call Google APIs on the user's behalfThe code in step 4 is single-use and short-lived. Why? Because the redirect URL can be seen in browser history and server logs. The actual token exchange happens server-to-server (step 5) never exposed to the browser. Clean.
// Step 5: Exchanging the code for tokens
const response = await fetch('https://oauth2.googleapis.com/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
client_id: process.env.GOOGLE_CLIENT_ID,
client_secret: process.env.GOOGLE_CLIENT_SECRET,
code: req.query.code,
redirect_uri: 'https://yourapp.com/callback',
grant_type: 'authorization_code'
})
});
const { access_token, refresh_token } = await response.json();Other OAuth 2.0 Grant Types
Client Credentials: Machine-to-machine with no user involved:
POST /oauth/token
{ client_id, client_secret, grant_type: "client_credentials" }
→ access_tokenThis is what your backend uses to call another service’s API. Think: your microservice is calling a payment API.
PKCE (Proof Key for Code Exchange): For mobile and SPA clients that can’t safely store a client_secret. Replaces the secret with a one-time code verifier/challenge pair. This is the modern standard for public clients.
// Generate PKCE challenge
const verifier = generateRandomString(128);
const challenge = base64url(sha256(verifier));
// In auth URL
&code_challenge=<challenge>
&code_challenge_method=S256
// In token exchange (instead of client_secret)
&code_verifier=<verifier>OAuth 2.0 is the backbone of the modern API economy. But notice: at no point did I say OAuth tells your app who the user is. It gives you an access token. That’s authorization. For identity, you need the next layer.
👍 Pros
Secure delegation
No password sharing
Industry standard
👎 Cons
Complex to implement
Requires proper flow handling
🔥 Popular Flows
Authorization Code Flow (most secure)
PKCE (for mobile apps)
7. OpenID Connect (OIDC): “Who Are You?” Built on OAuth
OpenID Connect is OAuth 2.0 with one critical addition: the ID Token.
OIDC answers the identity question that OAuth deliberately ignored. It adds a thin identity layer on top of the same OAuth 2.0 flows, same endpoints, and one additional token.
OAuth tells you:
👉 “This app can access your data”
OIDC tells you:
👉 “This is who the user is”
When you add openid to your OAuth scope:
scope=openid email profileThe authorization server returns not just an access_token, but also an id_token. The ID token is a JWT containing claims about the user:
📦 Example
{
"iss": "https://accounts.google.com",
"sub": "user_1234567890",
"email": "[email protected]",
"name": "Alice Smith",
"picture": "https://...",
"aud": "your_client_id",
"exp": 1700003600,
"iat": 1700000000
}Your app verifies this JWT’s signature using the authorization server’s public keys (fetched from a well-known endpoint), and now you know exactly who logged in without any additional API call.
👍 Why it matters
Standardized user authentication
Works with OAuth flows
Used by Google, Microsoft, and Auth0
When people say “Login with Google/GitHub/Apple,” OIDC is what’s happening under the hood every time.
8. SSO (Single Sign-On): One Login to Rule Them All
SSO is the experience, not the protocol.
One login → access multiple systems
SSO is built on top of the protocols we’ve discussed. Understanding SSO is understanding how those protocols compose.
Example:
Log in once to the company portal
Access to Slack, Jira, GitHub, Salesforce, and 40 other tools without logging into each one.
The Two Dominant SSO Protocols
SAML 2.0 (Security Assertion Markup Language): The enterprise veteran. XML-based, verbose, but deeply embedded in corporate tooling. Works through a three-party trust:
User → Service Provider (SP) → Identity Provider (IdP) → SAML Assertion → SP → AccessThe IdP (like Okta, ADFS, or Ping Identity) issues signed XML assertions that the SP validates. Your company’s IT team loves SAML because it’s been battle-tested for 20 years.
OIDC-based SSO: The modern approach. Same OAuth/OIDC flows, but the Identity Provider is your company’s auth system. When a user tries to access an app:
App redirects to your company's IdP
IdP checks if the user already has a session (SSO)
If yes → issues tokens immediately, no password prompt
If no → user logs in once, the session is established
Future app logins → automatic (the SSO session handles it)
[User hits App A] → [Redirect to IdP] → [Login, session created] → [ID token] → [App A access]
[User hits App B] → [Redirect to IdP] → [Session exists!] → [ID token] → [App B access] ✓ No login promptThe session at the IdP level is what powers the magic. All apps trust the same IdP, so one authentication event covers all of them.
Implementing SSO in your app:
// Using passport.js with OIDC for SSO
passport.use('oidc', new Strategy({
issuer: 'https://your-company.okta.com',
authorizationURL: 'https://your-company.okta.com/oauth2/v1/authorize',
tokenURL: 'https://your-company.okta.com/oauth2/v1/token',
userInfoURL: 'https://your-company.okta.com/oauth2/v1/userinfo',
clientID: process.env.CLIENT_ID,
clientSecret: process.env.CLIENT_SECRET,
callbackURL: '/callback',
scope: 'openid email profile'
}, (tokenSet, userInfo, done) => {
return done(null, userInfo);
}));SSO for microservices: The standard pattern is a dedicated Auth Gateway. All requests hit the gateway first. It validates tokens (JWT or session), injects user context into headers, and forwards to downstream services.
Client → API Gateway → [validate JWT] → [inject X-User-Id header] → Service A
→ Service B
→ Service C👍 Benefits
Better user experience
Centralized authentication
Stronger security policies
👎 Trade-offs
Complex setup
Single point of failure if not designed properly
🧩 How Everything Fits Together (Modern Architecture)
Let’s connect the dots:
Modern systems don’t pick one auth method — they use several, at different layers, for different purposes.
A realistic production system looks like this:
┌─────────────────────────────────────────┐
│ User-Facing Web App │
│ Login: OIDC/OAuth 2.0 (SSO via Okta) │
│ Session: JWT (access) + cookie stored │
│ Microservice calls: Bearer JWT │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Public Developer API │
│ Authentication: API Keys │
│ Rate limiting: Per API key │
│ Scoped access: OAuth 2.0 scopes │
└─────────────────────────────────────────┘
┌─────────────────────────────────────────┐
│ Service-to-Service (Internal) │
│ Auth: OAuth 2.0 Client Credentials │
│ Token: Short-lived JWT │
│ No user context needed │
└─────────────────────────────────────────┘Here’s the decision framework burned into the mind of every senior engineer:

The Security Non-Negotiables
Before you ship anything:
Always HTTPS: Everything above is compromised without TLS
HttpOnly + Secure cookies: Keeps session IDs/tokens away from JavaScript
Short token expiry: Access tokens: 15 minutes. Refresh tokens: days/weeks
PKCE for public clients: No client_secret in mobile or SPA code
Validate all JWT claims:
iss,aud,expall of them, every timeHash API keys at rest : Treat them like passwords
State parameter in OAuth: Your CSRF protection during auth flows
Scope minimally: Request only the permissions you actually need
🧠 If you remember just one thing:
Authentication is not a feature, it’s the foundation of trust in your system.
The Mental Model That Ties It All Together
Think of it this way:
Basic Auth & API Keys: “Here’s my credential, trust me”
Sessions: “I proved myself once, here’s my ticket”
JWT: “My ticket contains all the proof, check the signature”
OAuth 2.0: “Let me access their stuff on their behalf”
OIDC: “Let me know who they are, too”
SSO: “Let one login unlock everything”
Each step in this evolution solved a real problem with the previous approach. Understanding the problem each solved is understanding when to use each today.
Auth is not magic. It’s a series of very deliberate engineering decisions, each one balancing security, usability, and scale. Now you know what those decisions are.
Thank You for Reading!
I hope you found it helpful and informative. If you have any questions or feedback, feel free to leave a comment below. Your support and engagement mean a lot to me.
