Security

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.

March 9, 20267 min readShipSafer Team

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:

GrantUse case
Authorization Code + PKCEUser-facing web and mobile apps
Client CredentialsMachine-to-machine (no user involved)
Device AuthorizationCLI tools, TVs, IoT devices
ImplicitDeprecated — do not use
Resource Owner PasswordDeprecated — 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 (exp claim)
  • Hard to revoke — valid until expiration unless you maintain a blocklist

Critical implementation rules:

  1. Always validate the alg header. The none algorithm 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'
});
  1. Use short expiration for access tokens (15–60 minutes). Use refresh tokens for session persistence.

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

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

ScenarioRecommendation
Internal service-to-servicemTLS (service mesh) or Client Credentials OAuth2
Developer API key for your SaaSAPI key with hash storage
Third-party app accessing user dataOAuth2 Authorization Code + PKCE
Your app calling its own backendShort-lived JWT in HTTP-only cookie
CLI tool on behalf of userOAuth2 Device Authorization grant
High-security B2B integrationmTLS + API key (layered)
Webhook deliveryHMAC 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.

Check Your Security Score — Free

See exactly how your domain scores on DMARC, TLS, HTTP headers, and 25+ other automated security checks in under 60 seconds.