API Authentication Guide: API Keys, OAuth2, JWT, and mTLS Compared
Compare API keys, OAuth2, JWT, and mTLS for API authentication — when to use each, their security properties, and implementation patterns.
API Authentication Guide: API Keys, OAuth2, JWT, and mTLS Compared
Choosing the wrong authentication mechanism for your API is a category error that is painful to undo. API keys bolted onto a multi-tenant application that should have used OAuth2 create authorization headaches. JWTs used for session management without proper revocation create security gaps. mTLS deployed where simple API keys would suffice creates operational overhead with no security benefit.
This guide compares the four primary API authentication mechanisms, explains the security properties of each, and provides guidance on when each is the right choice.
API Keys
What they are: A long, random string — typically 32-64 characters — that identifies and authenticates a client. The key is sent with each request, usually in a header.
GET /api/v1/scans HTTP/1.1
Authorization: Bearer shss_3k9mX7pQ2nL8vR4tY6wJ1aE5bC0dF
Security properties:
- Simple to implement and use
- Opaque — carries no information about the bearer
- No built-in expiration (you must implement rotation)
- All-or-nothing — no scopes unless you implement them
- Susceptible to leakage (source code, logs, git history)
Revocation: Immediate — delete the key from your database and it stops working.
Best for:
- Server-to-server integrations where the client is a known, trusted service
- Public APIs where simplicity is valued over granular authorization
- Webhook authentication
Implementation pattern:
// Generate a new API key
import { randomBytes } from 'crypto';
function generateApiKey(prefix: string): string {
const bytes = randomBytes(32);
return `${prefix}_${bytes.toString('base64url')}`;
}
// Validate incoming requests
async function validateApiKey(key: string): Promise<User | null> {
// Hash the key before comparing — never store plaintext
const hashed = createHash('sha256').update(key).digest('hex');
return User.findOne({ apiKeyHash: hashed }).lean();
}
Never store API keys in plaintext. Store a SHA-256 hash and compare against the hash. Show the full key to the user exactly once at creation time. This way, even a full database dump does not expose valid credentials.
OAuth2
What it is: An authorization framework that enables a user (resource owner) to grant a third-party application (client) limited access to their account on a service (resource server), without sharing their credentials.
The four roles:
- Resource Owner — The user
- Client — The application requesting access
- Authorization Server — Issues tokens (e.g., Auth0, Cognito, your own IdP)
- Resource Server — The API being accessed
Grant types:
| Grant | Use case |
|---|---|
| Authorization Code + PKCE | User-facing web and mobile apps |
| Client Credentials | Machine-to-machine (no user involved) |
| Device Authorization | CLI tools, TVs, IoT devices |
| Implicit | Deprecated — do not use |
| Resource Owner Password | Deprecated — do not use |
For user-delegated access (web apps):
1. User clicks "Connect to GitHub"
2. App redirects to GitHub with client_id, scope, state, code_challenge
3. User approves
4. GitHub redirects back with authorization_code
5. App exchanges code + code_verifier for access_token + refresh_token
6. App uses access_token to call GitHub API on behalf of user
Security properties:
- User credentials never exposed to the client
- Scoped access — clients only get the permissions the user grants
- Short-lived access tokens (15 min–1 hour typical)
- Refresh tokens enable long-lived sessions without re-authentication
- Revocable at the user or application level
Best for:
- Any flow where a third-party app needs delegated access to user data
- Building a public API that third-party developers integrate with
- Federated identity (social login)
For machine-to-machine (Client Credentials):
// Obtain token
const response = await fetch('https://auth.example.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'client_credentials',
client_id: process.env.CLIENT_ID,
client_secret: process.env.CLIENT_SECRET,
audience: 'https://api.example.com'
})
});
const { access_token } = await response.json();
JWT (JSON Web Tokens)
What they are: JWT is a token format, not an authentication protocol. A JWT is a Base64URL-encoded JSON payload signed (and optionally encrypted) by the issuer. The signature allows the recipient to verify the token without a database lookup.
eyJhbGciOiJSUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiJ1c2VyLTEyMyIsInJvbGUiOiJhZG1pbiIsImV4cCI6MTc0MTUxOTkwMH0.
[signature]
Decoded payload:
{
"sub": "user-123",
"role": "admin",
"exp": 1741519900,
"iss": "https://auth.example.com",
"aud": "https://api.example.com"
}
Security properties:
- Stateless — no database lookup required for validation
- Self-contained — claims are embedded in the token
- Signed — tampering is detectable
- Has expiration (
expclaim) - Hard to revoke — valid until expiration unless you maintain a blocklist
Critical implementation rules:
- Always validate the
algheader. Thenonealgorithm attack and RS256-to-HS256 confusion attacks exploit improper algorithm validation.
import { jwtVerify } from 'jose';
const { payload } = await jwtVerify(token, publicKey, {
algorithms: ['RS256'], // Explicitly whitelist algorithms
issuer: 'https://auth.example.com',
audience: 'https://api.example.com'
});
-
Use short expiration for access tokens (15–60 minutes). Use refresh tokens for session persistence.
-
For session tokens, prefer short-lived JWTs + refresh token rotation over long-lived JWTs. A 1-hour JWT that is stolen gives the attacker 1 hour of access. A 7-day JWT gives them 7 days.
-
Never store JWTs in localStorage. Use HTTP-only, Secure, SameSite=Lax cookies.
Best for:
- Access tokens in OAuth2 flows
- Stateless API authentication where horizontal scaling makes session stores complex
- Passing claims between services in a microservices architecture
Not ideal for: Long-lived sessions that need to be instantly revocable (unless you maintain a blocklist, which eliminates the statelessness benefit).
mTLS (Mutual TLS)
What it is: Standard TLS authenticates the server to the client. Mutual TLS adds client certificate authentication — both parties present and verify each other's X.509 certificates.
How it works:
1. Client → Server: ClientHello
2. Server → Client: Certificate (server cert)
3. Client verifies server cert against trusted CA
4. Server → Client: CertificateRequest
5. Client → Server: Certificate (client cert)
6. Server verifies client cert against trusted CA
7. Encrypted session established
Security properties:
- Cryptographically strong client authentication — the client must possess the private key
- Resistant to phishing and credential stuffing (no passwords)
- Certificates can be pinned and short-lived
- Revocable via CRL or OCSP
- Certificate management adds operational complexity
Best for:
- Service mesh communication (Istio/Linkerd enforce mTLS between all pods)
- High-security B2B API integrations (financial services, healthcare)
- Zero trust network access (ZTNA)
- Environments where you control both ends of the connection
Certificate management for mTLS:
Short-lived certificates (24–72 hours) with automated rotation via SPIFFE/SPIRE or cert-manager eliminate the need for CRL/OCSP checks and reduce the blast radius of certificate compromise.
Decision Matrix
| Scenario | Recommendation |
|---|---|
| Internal service-to-service | mTLS (service mesh) or Client Credentials OAuth2 |
| Developer API key for your SaaS | API key with hash storage |
| Third-party app accessing user data | OAuth2 Authorization Code + PKCE |
| Your app calling its own backend | Short-lived JWT in HTTP-only cookie |
| CLI tool on behalf of user | OAuth2 Device Authorization grant |
| High-security B2B integration | mTLS + API key (layered) |
| Webhook delivery | HMAC signature on payload body |
The right choice depends on who controls both ends of the connection, whether a user is involved, and how much operational complexity you can absorb. When in doubt, OAuth2 Client Credentials for M2M and OAuth2 Authorization Code + PKCE for user-delegated access are the safest general-purpose choices.