API Authentication: API Keys vs Sessions vs JWT
API authentication compared: API keys for apps, server-side sessions, self-contained JWTs, revocation and refresh tokens, and Sign in with Google (OIDC).
Authentication (authN) answers "who are you?"; authorization (authZ) answers "what are you allowed to do?" A login proves identity; a permission check decides whether that identity may delete this document. They are separate layers, usually handled by separate components, and mixing them up is a common source of security bugs. This page covers authentication: API keys, sessions and JWTs, and how "Sign in with Google" works. Permissions (RBAC, ABAC, OAuth scopes) are covered in authorization.
Every API that isn't fully public needs to identify its callers. Which method fits depends on who the caller is (an application or a person), how the system scales, and how quickly you must be able to revoke access.
API keys: identifying applications
An API key identifies an application, not a user. Each client gets its own unique key, which it sends with every request. Use keys when you need to know which application is calling (for usage tracking, billing and per-client rate limits) but not which person is behind it.
GET /v1/charges
Authorization: Bearer sk_test_your_secret_keyA key works like a password for the app. Because the server knows exactly which application sent each request, it can rate limit and meter per client. GitHub, for example, allows 5,000 requests an hour with a valid token but only 60 an hour anonymously.
Keys are long-lived (months or years), so their lifecycle needs managing: rotate them regularly, monitor usage, revoke them instantly when leaked, and store only a hash on the server. AWS provides tooling to rotate access keys and flag unused ones. Give keys a recognizable prefix (sk_live_) so leaked keys can be detected by secret scanners.
Sessions: state on the server
Sessions keep the user's login state on the server. When a user signs in, the server creates a session record (user ID, roles, a CSRF token) and gives the browser only an opaque, random session ID, usually in a cookie. Every later request sends the cookie, and the server looks the session up.
- Rich user context. The session can hold the user ID and lightweight state such as role IDs and a CSRF token. Keep it small and look up larger data (the full profile) from its own store; small sessions use less memory and are easier to invalidate.
- Strong security controls. Banking apps use 15-minute idle timeouts, cookie flags
HttpOnly(JavaScript can't read it),Secure(HTTPS only) andSameSite(limits cross-site sending), and kill sessions immediately on suspicious activity. - Instant revocation. Logging out or banning a user is just deleting the session record.
- Scaling cost. Sessions in one server's memory break as soon as there are two servers. Reddit moved from in-memory sessions to Redis-backed sessions so any application server could handle any request; that enabled horizontal scaling but made Redis a critical dependency. See stateless vs stateful.
- Awkward for non-browsers. Cookies suit web apps; mobile apps and API clients usually prefer tokens.
A minimal, secure session manager:
import secrets, time
class SessionManager:
def __init__(self, store, timeout_seconds=1800): # 30-minute idle timeout
self.store = store # e.g. Redis, shared by all servers
self.timeout = timeout_seconds
def create(self, user_id, role_ids):
session_id = secrets.token_urlsafe(32) # unguessable, 256 bits of randomness
self.store.set(session_id, {
"user_id": user_id,
"role_ids": role_ids,
"csrf": secrets.token_urlsafe(16),
"last_seen": time.time(),
}, ttl=self.timeout)
return session_id # sent as an HttpOnly, Secure, SameSite cookie
def validate(self, session_id):
session = self.store.get(session_id)
if session is None: # unknown, expired or revoked
return None
if time.time() - session["last_seen"] > self.timeout:
self.invalidate(session_id)
return None
session["last_seen"] = time.time() # sliding expiry
self.store.set(session_id, session, ttl=self.timeout)
return session
def invalidate(self, session_id): # logout, password change, suspicious activity
self.store.delete(session_id)JWTs: self-contained tokens
A JSON Web Token (JWT) carries the user's identity and permissions inside the token itself, signed by the issuer. A service validates it cryptographically, with no database or session lookup, so authentication becomes stateless and any service in a distributed system can check it.
A JWT has three base64url-encoded parts joined by dots: header.payload.signature.
- Claims and permissions. The payload holds claims such as the user ID (
sub), roles, issue time (iat) and expiry (exp). The server checks them locally. It still has to fetch the issuer's public keys (a JWKS endpoint) to verify signatures, and must validate the issuer, audience and expiry with a little clock-skew tolerance. Put role IDs in the token rather than long permission lists, to keep tokens small. - The revocation problem. A JWT stays valid until it expires; you can't delete it like a session. Production systems use short-lived access tokens with refresh tokens, or keep a denylist of revoked token IDs for critical cases. Choose the lifetime by risk: 5–15 minutes for high-risk apps, 15–60 minutes for typical web apps, longer for some mobile cases.
Creating and validating an HS256 token by hand shows what libraries do for you:
import base64, hashlib, hmac, json, time
def b64url(data: bytes) -> str:
return base64.urlsafe_b64encode(data).decode().rstrip("=")
def b64url_decode(text: str) -> bytes:
return base64.urlsafe_b64decode(text + "=" * (-len(text) % 4))
def create_token(secret: bytes, user_id: str, claims: dict, expires_in=900) -> str:
header = {"alg": "HS256", "typ": "JWT"}
now = int(time.time())
payload = {"sub": user_id, "iat": now, "exp": now + expires_in, **claims}
signing_input = ".".join(b64url(json.dumps(part, separators=(",", ":")).encode()) for part in (header, payload))
signature = hmac.new(secret, signing_input.encode(), hashlib.sha256).digest()
return f"{signing_input}.{b64url(signature)}"
def validate_token(secret: bytes, token: str):
try:
header_b64, payload_b64, signature_b64 = token.split(".")
except ValueError:
return None
if json.loads(b64url_decode(header_b64)).get("alg") != "HS256": # pin the algorithm
return None
expected = hmac.new(secret, f"{header_b64}.{payload_b64}".encode(), hashlib.sha256).digest()
if not hmac.compare_digest(expected, b64url_decode(signature_b64)): # constant-time compare
return None
payload = json.loads(b64url_decode(payload_b64))
if payload["exp"] < time.time() - 30: # 30 s clock-skew tolerance
return None
return payloadJWTs are signed, not encrypted: anyone holding one can read its claims. Don't put secrets in them, always verify the signature and expiry, pin the expected algorithm, and keep them out of localStorage when an HttpOnly cookie will do (to limit theft through XSS). In production use a maintained library rather than hand-rolled code.
Choosing an authentication method
Each method suits a different caller. API keys fit service-to-service calls and per-client rate limiting. Sessions fit traditional web apps that need rich user context and instant logout. JWTs fit distributed systems, microservices and mobile apps where many services must verify identity without a shared store.
| Method | Stateless? | User context | Immediate revocation | Horizontal scaling |
|---|---|---|---|---|
| API key | Often no (looked up in a DB) | None: identifies an app | Easy if checked on the server | Excellent with a shared cache |
| Session | No | Rich (server-side) | Excellent: delete the session | Needs a shared session store |
| JWT | Yes (verified locally) | Embedded in the token | Hard: wait for expiry or keep a denylist | Excellent |
A common hybrid: short-lived access tokens (5–15 minutes) verified locally, plus a long-lived refresh token stored server-side that can be revoked. Revoking the refresh token stops new access tokens within minutes.
Real systems mix methods. GitHub uses personal access tokens (user-generated API keys) for its REST API, OAuth tokens for third-party apps, and sessions for its website, using each where it fits best instead of forcing one approach everywhere.
Sign in with Google: OpenID Connect
OAuth 2.0 lets a user grant an application limited access to their data on another service without sharing their password; see authorization. OpenID Connect (OIDC) adds an ID token on top, so the same flow also tells the application who the user is. That is "Sign in with Google".
- Use the authorization code flow with PKCE for web and mobile apps; the old implicit flow is deprecated.
- Validate the state parameter to prevent CSRF on the callback.
- Use the client credentials flow for service-to-service calls with no user.
Where authentication lives in an architecture
- The API gateway authenticates each request (validates the session, key or token) and passes a verified identity to the services behind it.
- Services make authorization decisions with domain knowledge. See authorization.
- Internal service-to-service calls use mTLS identities or short-lived service tokens. See service mesh.
- Protect credentials: hash passwords with bcrypt, scrypt or Argon2; add MFA; rate limit logins and OTP requests; keep keys in a secrets manager.
Where it’s used
Secret API keys or personal access tokens identify the calling app or developer, with per-key rate limits and instant revocation.
Server-side sessions with short idle timeouts and strict cookie flags, so a session can be killed the moment fraud is suspected.
Short-lived JWT access tokens verified locally by every service, renewed with revocable refresh tokens.
OpenID Connect authorization code flows let apps authenticate users without handling their passwords. Providers include Auth0, Okta, Cognito and Keycloak.
In an interview
- Say who the caller is first: an application (API key), a browser user (session) or a distributed client (JWT).
- Explain revocation: instant for sessions and server-checked keys, delayed for JWTs unless tokens are short-lived or denylisted.
- Authenticate once at the gateway, then authorize per request and per object in the services.
- Mention secure cookie flags, password hashing and rate-limited logins as defaults.