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:

  1. You send your username and password (once)

  2. The server validates them and creates a session — a record in its database with a unique session ID and your user info

  3. The server sends that session ID back to your browser in a cookie

  4. Every subsequent request, your browser automatically sends that cookie

  5. 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=Strict

The 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_abc123def456

Or sometimes in a custom header:

X-API-Key: your_api_key_here

Or as a query parameter (please don’t — it shows up in logs):

GET /api/data?api_key=abc123   ← please no

Under 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_adQssw5c

Three 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

  1. Server signs token with secret/private key

  2. Client stores token (usually localStorage/cookie)

  3. Client sends a token with each request

  4. 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 DB

JWT is powerful, but dangerous if misused.

JWT pitfalls to avoid:

  • alg: none Never accept unsigned tokens

  • Sensitive 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 HttpOnly cookies

💡 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.

6. OAuth 2.0:  Delegated Authorization, Not Authentication

🧠 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)

The Authorization Code Flow (the most common, most secure)

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 behalf

The 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_token

This 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

  • 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 profile

The 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 → Access

The 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:

  1. App redirects to your company's IdP

  2. IdP checks if the user already has a session (SSO)

  3. If yes → issues tokens immediately, no password prompt

  4. If no → user logs in once, the session is established

  5. 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 prompt

The 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:

  1. Always HTTPS:  Everything above is compromised without TLS

  2. HttpOnly + Secure cookies:  Keeps session IDs/tokens away from JavaScript

  3. Short token expiry:  Access tokens: 15 minutes. Refresh tokens: days/weeks

  4. PKCE for public clients:  No client_secret in mobile or SPA code

  5. Validate all JWT claims:  iss, aud, exp  all of them, every time

  6. Hash API keys at rest : Treat them like passwords

  7. State parameter in OAuth:  Your CSRF protection during auth flows

  8. 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.

Happy Coding!

Reply

Avatar

or to participate