Skip to main content

API Authentication Guide: Keys, OAuth & JWT (2026)

·APIScout Team
Share:

Every API needs authentication. But "just add a Bearer token" skips the hard parts: token refresh cycles, key rotation automation, PKCE for SPAs, HMAC webhook verification. This guide covers each method with production-ready code — not just theory.

TL;DR

API keys for server-to-server and internal APIs. OAuth 2.0 with PKCE for any user-facing flow. JWT for stateless microservice auth where you need embedded claims. mTLS for high-security service mesh environments. The "best" method depends on your threat model, not your preference.

Key Takeaways

  • API keys are not simpler than JWTs — secure key storage, rotation, and scoping are significant engineering work
  • OAuth 2.0 PKCE is mandatory for SPAs — the Authorization Code flow without PKCE is insecure in browser environments
  • JWTs should be short-lived (15 min) — long-lived JWTs are effectively permanent credentials if leaked
  • HMAC-SHA256 for webhooks — signature verification prevents webhook spoofing attacks
  • Token refresh should happen proactively — refresh 60 seconds before expiry, not after a 401

API Keys: Secure Implementation

API keys look simple but have serious implementation requirements. A raw key in an environment variable is a start, but production security requires hashing, scoping, and rotation.

Key Generation and Hashing

Never store raw API keys. Store a SHA-256 hash and give the user the plaintext only once — like how Stripe and GitHub handle it.

const crypto = require('crypto');

function generateApiKey() {
  // 32 bytes = 256 bits of entropy
  const rawKey = crypto.randomBytes(32).toString('hex');
  const prefix = 'sk_live_'; // visible prefix for identification
  const fullKey = `${prefix}${rawKey}`;

  // Hash for storage — never store plaintext
  const hash = crypto
    .createHash('sha256')
    .update(fullKey)
    .digest('hex');

  return { fullKey, hash }; // show fullKey once, store hash
}

async function validateApiKey(incomingKey, db) {
  const hash = crypto
    .createHash('sha256')
    .update(incomingKey)
    .digest('hex');

  const keyRecord = await db.apiKeys.findOne({ hash });
  if (!keyRecord || keyRecord.revokedAt) return null;

  // Update last-used timestamp for rotation tracking
  await db.apiKeys.updateOne({ hash }, { lastUsed: new Date() });
  return keyRecord;
}

Key Rotation

Rotating keys without downtime requires a transition window:

// Allow both old and new key for 24 hours during rotation
async function rotateKey(userId, db) {
  const { fullKey: newKey, hash: newHash } = generateApiKey();
  const expiresAt = new Date(Date.now() + 24 * 60 * 60 * 1000);

  // Mark old key as pending-rotation (still valid for 24h)
  await db.apiKeys.updateMany(
    { userId, status: 'active' },
    { status: 'rotating', expiresAt }
  );

  // Create new key
  await db.apiKeys.create({ userId, hash: newHash, status: 'active' });

  return newKey; // send to user via secure channel
}

Scoping Keys

Fine-grained scopes prevent over-privileged keys:

const KEY_SCOPES = {
  'read:apis': 'Read API listings',
  'write:reviews': 'Submit reviews',
  'admin:keys': 'Manage API keys',
};

function middlewareRequireScope(scope) {
  return async (req, res, next) => {
    const key = await validateApiKey(req.headers.authorization?.replace('Bearer ', ''), db);
    if (!key) return res.status(401).json({ error: 'Invalid API key' });
    if (!key.scopes.includes(scope)) {
      return res.status(403).json({ error: `Missing scope: ${scope}` });
    }
    req.keyRecord = key;
    next();
  };
}

// Usage
app.get('/apis', middlewareRequireScope('read:apis'), listApis);
app.post('/reviews', middlewareRequireScope('write:reviews'), createReview);

OAuth 2.0: PKCE for SPAs

The Authorization Code flow without PKCE is not safe for browser apps. PKCE (Proof Key for Code Exchange) prevents authorization code interception attacks.

PKCE Flow Implementation

// In the browser (SPA)
async function initiateOAuthFlow() {
  // 1. Generate code verifier (43-128 random chars)
  const codeVerifier = base64URLEncode(crypto.getRandomValues(new Uint8Array(32)));
  sessionStorage.setItem('pkce_verifier', codeVerifier);

  // 2. Create code challenge (SHA-256 hash of verifier)
  const encoder = new TextEncoder();
  const data = encoder.encode(codeVerifier);
  const digest = await crypto.subtle.digest('SHA-256', data);
  const codeChallenge = base64URLEncode(new Uint8Array(digest));

  // 3. Redirect to authorization server
  const params = new URLSearchParams({
    client_id: 'your-client-id',
    redirect_uri: 'https://yourapp.com/callback',
    response_type: 'code',
    scope: 'openid profile email',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state: crypto.randomUUID(), // CSRF protection
  });

  window.location.href = `https://auth.provider.com/authorize?${params}`;
}

// Handle callback
async function handleCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const codeVerifier = sessionStorage.getItem('pkce_verifier');

  // Exchange code + verifier for tokens
  const response = await fetch('https://auth.provider.com/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      code,
      redirect_uri: 'https://yourapp.com/callback',
      client_id: 'your-client-id',
      code_verifier: codeVerifier, // proves you initiated the flow
    }),
  });

  const tokens = await response.json();
  // tokens.access_token, tokens.refresh_token, tokens.expires_in
  sessionStorage.removeItem('pkce_verifier');
  return tokens;
}

Client Credentials (Machine-to-Machine)

For server-to-server, no user context needed:

// Server-side token fetch and cache
class OAuthM2MClient {
  constructor(clientId, clientSecret, tokenUrl) {
    this.clientId = clientId;
    this.clientSecret = clientSecret;
    this.tokenUrl = tokenUrl;
    this._token = null;
    this._expiresAt = 0;
  }

  async getToken() {
    // Refresh 60 seconds before expiry
    if (this._token && Date.now() < this._expiresAt - 60_000) {
      return this._token;
    }

    const response = await fetch(this.tokenUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
      body: new URLSearchParams({
        grant_type: 'client_credentials',
        client_id: this.clientId,
        client_secret: this.clientSecret,
        scope: 'api:read api:write',
      }),
    });

    const data = await response.json();
    this._token = data.access_token;
    this._expiresAt = Date.now() + data.expires_in * 1000;
    return this._token;
  }

  async fetch(url, options = {}) {
    const token = await this.getToken();
    return fetch(url, {
      ...options,
      headers: { ...options.headers, Authorization: `Bearer ${token}` },
    });
  }
}

JWT: Structure, Validation, and Refresh

JWT Validation (Don't Trust jwt.decode)

jwt.decode() in most libraries does NOT verify the signature — it only parses the payload. Always use jwt.verify():

const jwt = require('jsonwebtoken');

// ❌ WRONG — doesn't verify signature
const payload = jwt.decode(token);

// ✅ CORRECT — verifies signature and expiration
function validateJWT(token) {
  try {
    const payload = jwt.verify(token, process.env.JWT_SECRET, {
      algorithms: ['HS256'], // whitelist algorithms — never allow 'none'
      issuer: 'https://api.yourapp.com',
      audience: 'api-clients',
    });
    return { valid: true, payload };
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return { valid: false, reason: 'expired' };
    }
    return { valid: false, reason: 'invalid' };
  }
}

Token Issuance

function issueTokenPair(userId, scopes) {
  const accessToken = jwt.sign(
    {
      sub: userId,
      scope: scopes.join(' '),
      iat: Math.floor(Date.now() / 1000),
    },
    process.env.JWT_SECRET,
    {
      expiresIn: '15m', // short-lived — rotate via refresh token
      issuer: 'https://api.yourapp.com',
      audience: 'api-clients',
    }
  );

  // Refresh token stored in DB for revocation support
  const refreshToken = crypto.randomBytes(40).toString('hex');

  return { accessToken, refreshToken };
}

Proactive Refresh

Don't wait for a 401 to refresh. Check expiry on every request and refresh preemptively:

class JWTClient {
  constructor(refreshUrl) {
    this.refreshUrl = refreshUrl;
    this.accessToken = null;
    this.refreshToken = localStorage.getItem('refresh_token');
  }

  isExpiringSoon() {
    if (!this.accessToken) return true;
    const { exp } = JSON.parse(atob(this.accessToken.split('.')[1]));
    // Refresh if less than 60 seconds remain
    return exp * 1000 - Date.now() < 60_000;
  }

  async ensureFreshToken() {
    if (!this.isExpiringSoon()) return;

    const response = await fetch(this.refreshUrl, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ refreshToken: this.refreshToken }),
    });

    if (!response.ok) {
      // Refresh token expired — user must re-authenticate
      this.logout();
      return;
    }

    const data = await response.json();
    this.accessToken = data.accessToken;
    this.refreshToken = data.refreshToken; // rotate refresh token too
    localStorage.setItem('refresh_token', this.refreshToken);
  }

  async fetch(url, options = {}) {
    await this.ensureFreshToken();
    return fetch(url, {
      ...options,
      headers: { ...options.headers, Authorization: `Bearer ${this.accessToken}` },
    });
  }
}

HMAC Webhook Verification

Webhooks arrive as unsigned HTTP POST requests — without verification, any server can send fake events. HMAC-SHA256 signatures solve this.

// Webhook sender (e.g., Stripe's approach)
function signWebhookPayload(payload, secret) {
  const timestamp = Math.floor(Date.now() / 1000);
  const signedPayload = `${timestamp}.${payload}`;
  const signature = crypto
    .createHmac('sha256', secret)
    .update(signedPayload)
    .digest('hex');

  return {
    'Stripe-Signature': `t=${timestamp},v1=${signature}`,
  };
}

// Webhook receiver
function verifyWebhook(req, webhookSecret) {
  const sig = req.headers['stripe-signature'];
  const timestamp = sig.match(/t=(\d+)/)?.[1];
  const receivedSig = sig.match(/v1=([a-f0-9]+)/)?.[1];

  if (!timestamp || !receivedSig) {
    throw new Error('Missing webhook signature');
  }

  // Replay attack prevention — reject events older than 5 minutes
  const timeDiff = Math.abs(Date.now() / 1000 - parseInt(timestamp));
  if (timeDiff > 300) {
    throw new Error('Webhook timestamp too old');
  }

  // Recompute expected signature
  const rawBody = req.body; // must be raw Buffer, not parsed JSON
  const expectedSig = crypto
    .createHmac('sha256', webhookSecret)
    .update(`${timestamp}.${rawBody}`)
    .digest('hex');

  // Timing-safe comparison prevents timing attacks
  if (!crypto.timingSafeEqual(
    Buffer.from(receivedSig, 'hex'),
    Buffer.from(expectedSig, 'hex')
  )) {
    throw new Error('Invalid webhook signature');
  }

  return JSON.parse(rawBody);
}

The crypto.timingSafeEqual() call is critical — a naive === comparison leaks timing information that enables signature forgery.


Comparison: Which Method for Which Scenario?

MethodComplexityUser ContextRevocableBest For
API KeyLowNoYes (DB lookup)Server-to-server, internal APIs
OAuth 2.0HighYesYes (token revocation)User-facing, third-party access
JWTMediumOptionalNo (until expiry)Microservices, stateless auth
HMACLowNoN/AWebhook verification
mTLSHighNoYes (CRL/OCSP)High-security service mesh

JWT revocation caveat: Standard JWTs can't be revoked before expiry without a token blacklist (which requires a DB lookup on every request — defeating the "stateless" benefit). Keep access tokens short-lived (15 min) and use refresh tokens for session management.


Security Checklist Before Going to Production

  • API keys hashed in the database — never plaintext
  • Key rotation tooling — automated, not manual
  • PKCE on all OAuth flows — even if your library adds it automatically
  • JWT algorithm whitelistalgorithms: ['HS256'], never allow 'none'
  • JWT expiry ≤ 15 minutes — longer is a security liability
  • Refresh token rotation — issue a new refresh token on every use
  • HMAC timingSafeEqual — standard string comparison is vulnerable
  • Replay attack prevention — timestamp check on webhooks (5 min window)
  • Scoped credentials — principle of least privilege on all keys and tokens
  • Auth events logged — failed auth attempts should alert at >10/minute

mTLS: When You Need It

Mutual TLS (mTLS) is the authentication method most teams never need — until they do, at which point nothing else will satisfy the requirement. In standard TLS, only the server presents a certificate to prove its identity to the client. In mTLS, both parties present certificates: the client proves its identity to the server at the TLS layer, before any HTTP request is processed and before any application code runs. This means authentication happens below the application layer entirely — there's no Bearer token, no API key header, no session cookie to steal or replay.

The use cases where mTLS is appropriate are specific: internal service meshes where every service must cryptographically prove its identity to every other service (zero-trust architecture), financial services where regulatory requirements mandate strong mutual authentication between trading systems, and healthcare environments under HIPAA where the same requirements apply to clinical data exchange. For a public-facing API serving third-party developers, mTLS is the wrong choice — it requires every client to manage certificates, which is operationally burdensome and kills adoption.

Setting up mTLS requires each service to have a certificate signed by a shared internal Certificate Authority. The API server is configured to require and verify the client certificate at the TLS handshake; if the client doesn't present a valid certificate signed by the trusted CA, the connection is rejected before any HTTP traffic flows. Certificate lifecycle management is the main operational challenge: certificates expire, and you need automated rotation to avoid service outages. HashiCorp Vault's PKI secrets engine is the standard tool for issuing short-lived certificates to services automatically. For Kubernetes environments, cert-manager handles certificate issuance and rotation. AWS ACM Private CA provides a managed option if you're already on AWS and want to avoid running your own CA. In a zero-trust architecture, mTLS often replaces API keys entirely — the certificate is the credential, and it's hardware-bound and automatically rotated rather than stored in an environment variable.

Choosing the Right Method: Decision Tree

The auth method comparison table tells you what each approach does, but it doesn't tell you what to pick for your situation. Here's a practical decision framework.

For server-to-server communication where there's no user context involved, the decision comes down to security requirements. If the integration is straightforward and the services are controlled by the same team — internal microservices, a backend calling a third-party data API — an API key is the right default. It's simple to implement, easy to rotate, and carries no unnecessary complexity. If the server-to-server communication is between services in a regulated environment, or between services in different trust zones where you can't assume the network is safe, mTLS is the appropriate upgrade. The certificate-based model eliminates credential theft as an attack vector.

When users need to authorize your application to access their data on another platform — connecting your app to their GitHub, Google Drive, or Salesforce account — OAuth 2.0 with the Authorization Code flow plus PKCE is the correct and only reasonable choice. This is what OAuth was designed for. Using API keys in this scenario means your users are handing you their credentials, which is the pattern OAuth was explicitly created to eliminate.

For microservices that need to verify tokens from other services without making a database round-trip on every request, JWTs with short expiry are the right fit. The token carries the claims, the signature is verifiable with a shared secret or public key, and no network call is needed for validation. This is the genuine benefit of JWTs — stateless verification. It's also where teams go wrong: using JWTs as long-lived API keys defeats this purpose and turns them into hard-to-revoke permanent credentials. When you receive events from third-party services via webhooks, HMAC signature verification is the appropriate mechanism — it's not an "auth method" in the user-facing sense, but it's the standard for proving that an inbound POST request genuinely came from the expected sender.

Common wrong choices worth avoiding explicitly: using JWTs as API keys conflates an encoding format with an auth method — JWT is how you encode claims, not a standalone auth strategy. Applying OAuth 2.0 for internal service authentication adds unnecessary complexity; the authorization server becomes a dependency and a failure point for every service call. Using long-lived JWTs (days or weeks) instead of the access-plus-refresh-token pattern eliminates the ability to revoke compromised tokens without a blacklist, which reintroduces the stateful database lookup you were trying to avoid.

Auth Monitoring in Production

Shipping auth code is the beginning, not the end. Authentication systems are the primary target of automated attack tooling, and without active monitoring you won't know you're under attack until the damage is done.

The first priority is comprehensive logging. Every failed authentication attempt should be logged with the IP address, user agent, timestamp, and the credential that was attempted (key prefix or username — never the full credential). Successful authentications should be logged too, with enough context to reconstruct a user's session history if needed during an incident. Key rotation events and scope violations — requests using a valid key for an unauthorized endpoint — should be logged as security events with higher priority than routine auth logs.

Alert thresholds should be tuned to detect credential attacks before they succeed. More than 10 failed authentication attempts per minute from a single IP is consistent with credential stuffing — automated testing of username/password pairs from a breach list. More than 100 failed attempts per minute from a single IP suggests active brute force. Both thresholds should trigger automated alerts; the credential stuffing threshold might warrant an automatic IP block, while brute force almost certainly does. If your auth system uses rate limiting (it should), log rate limit events separately so you can distinguish a legitimate high-volume client from an attacker hitting your limits.

For tooling, Datadog's authentication metrics and APM are well-suited to tracking auth success/failure rates with dimensional breakdowns by endpoint, key prefix, and IP. Sentry captures auth errors in context with stack traces, which is useful during development and for catching unexpected exception paths in production. AWS CloudTrail is the right choice for auditing API key usage if you're on AWS infrastructure — it provides tamper-resistant logs of every API call with identity context. On the HTTP response side, include WWW-Authenticate headers on every 401 response to tell clients which auth scheme to use; include X-Content-Type-Options: nosniff on all API responses to prevent MIME-type confusion attacks where a browser treats an API error response as executable content.

Methodology

  • Research drawn from: OAuth 2.0 RFC 6749, PKCE RFC 7636, JWT RFC 7519, Node.js crypto documentation, Stripe webhook verification documentation
  • Code examples tested against Node.js 22 LTS
  • Security practices cross-referenced with OWASP API Security Top 10 (2023)

Choosing your auth method is step one — see our API security checklist for what to harden before launch. Compare specific auth provider APIs at auth0-vs-clerk or Firebase Auth.

The API Integration Checklist (Free PDF)

Step-by-step checklist: auth setup, rate limit handling, error codes, SDK evaluation, and pricing comparison for 50+ APIs. Used by 200+ developers.

Join 200+ developers. Unsubscribe in one click.