API authentication: API keys vs. OAuth2 vs. JWT — what's actually different, and when do you combine them?
API keys, OAuth2 and JWT get treated as competitors, but each answers a different question about identity, delegation, and token shape.
LEARN BY DOINGPut your intuition to work3 steps
API authentication: API keys vs. OAuth2 vs. JWT — what's actually different, and when do you combine them?
Use the lesson’s examples and explanation to check your idea.
Personal reflection. Nothing is sent to an AI service.#Three different layers being compared
This question gets asked because candidates often treat "API key," "OAuth2," and "JWT" as three competing options, when they actually answer three different questions and are not mutually exclusive:
- An API key answers "which application is calling me?" — it is a static credential that identifies a client, with no inherent notion of a human user behind it.
- OAuth2 answers "how does a user grant a third-party application limited access to their resources, without handing over their password?" — it is a delegation and authorization framework, a set of flows (grant types) for obtaining a token.
- A JWT answers "what does a self-contained, verifiable token look like?" — it is a token format (a signed, base64url-encoded structure), not a protocol. OAuth2 access tokens are often — but not required to be — JWTs.
Mixing these up in an interview answer is the single most common mistake: saying "we use JWT instead of OAuth2" does not quite parse, because one is a workflow and the other is a token shape that workflow can (optionally) produce.
#API keys
An API key is a long, static, opaque secret sent on every request, typically in a header:
curl https://api.example.com/v1/orders \-H "X-API-Key: sk_live_51Hh2c9K4f9pQeT2xRedacted"
It is cheap to implement and well suited to server-to-server traffic, public API tiers, and metering usage per client — but it has real weaknesses: it carries no user identity, it usually does not expire on its own, and revoking a leaked key requires a lookup and rotation rather than the key simply timing out. Treat an API key as identifying the application, not the person using it.
#OAuth2
OAuth2 is a framework built around four roles — the resource owner (the user), the client (the app), the authorization server (issues tokens), and the resource server (the API being protected) — and a handful of grant types for different situations. A server-to-server integration with no human in the loop typically uses the client credentials grant:
curl -X POST https://auth.example.com/oauth/token \-H "Content-Type: application/x-www-form-urlencoded" \-d "grant_type=client_credentials" \-d "client_id=partner-integration-42" \-d "client_secret=REDACTED" \-d "scope=orders:read"
{"access_token": "eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...","token_type": "Bearer","expires_in": 3600,"scope": "orders:read"}
A user-facing application instead uses the authorization code grant (ideally with PKCE), which produces both a short-lived access token and a longer-lived refresh token, so the app never has to store the user's password.
#JWT
A JWT (JSON Web Token) is three base64url-encoded segments — header, payload, signature — separated by dots. The payload carries claims:
{"iss": "https://auth.example.com/","sub": "user_9f83a1","aud": "orders-api","scope": "orders:read orders:write","exp": 1755792000,"iat": 1755788400}
Because it is signed (commonly RS256 or HS256), a resource server can verify a JWT's authenticity and read its claims without calling back to the authorization server — that statelessness is the main reason OAuth2 access tokens are so often issued as JWTs:
const jwt = require("jsonwebtoken");function verifyAccessToken(token) {return jwt.verify(token, PUBLIC_KEY, {algorithms: ["RS256"],audience: "orders-api",issuer: "https://auth.example.com/",});}
#Combining them in practice
A common real-world shape uses an API key to identify which application is calling, and an OAuth2-issued JWT to identify which user that application is acting on behalf of — both present on the same request:
X-API-Key: sk_live_51Hh2c9K4f9pQeT2xRedactedAuthorization: Bearer eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9...
function authenticate(req, res, next) {const apiKey = req.header("X-API-Key");const authHeader = req.header("Authorization");if (!apiKey || !KNOWN_CLIENTS.has(apiKey)) {return res.status(401).json({ error: "invalid_client" });}const token = authHeader && authHeader.startsWith("Bearer ")? authHeader.slice(7): null;if (!token) {return res.status(401).json({ error: "missing_token" });}try {req.user = verifyAccessToken(token); // who: the acting userreq.client = KNOWN_CLIENTS.get(apiKey); // what: the calling appnext();} catch (err) {return res.status(401).json({ error: "invalid_token" });}}
This lets you apply per-application rate limits and per-user authorization checks independently, in the same request, which a single credential could not express on its own.
| Answers | Typical format | Expiry | Revocation | |
|---|---|---|---|---|
| API key | Which app is calling? | Opaque static string | Usually none | Manual rotation |
| OAuth2 | How does a user delegate access? | A protocol/flow, not a format | Access token: short. Refresh token: long | Refresh token revocation, short access token TTL |
| JWT | What does the token look like? | Signed header.payload.signature | Whatever exp claim says | Hard before exp unless using a denylist |