Skip to main content
Back to blog
April 28, 2026|8 min read

API Authentication Best Practices: API Keys, JWT & OAuth Compared

Choosing the wrong API authentication method — or implementing the right one incorrectly — is one of the most common causes of API breaches. Here is how to get it right.

ZeriFlow Team

1,396 words

API Authentication Best Practices: API Keys, JWT & OAuth Compared

API authentication best practices have matured significantly, yet misconfigurations remain one of the top causes of data exposure in web applications. This guide compares the three main approaches — API keys, JWTs, and OAuth 2.0 — explains when to use each, and provides production-ready implementation patterns.

Check your site's security configuration: Free ZeriFlow scan in 60 seconds →

Choosing the Right Authentication Method

Use CaseRecommended Method
Server-to-server (machine-to-machine)API keys or mTLS
User-facing mobile/SPAOAuth 2.0 + JWT (Authorization Code + PKCE)
Microservice internal authJWT (RS256) or mTLS
Third-party integrationsOAuth 2.0
Simple internal toolsAPI keys

The rule: if a human is authenticating, use OAuth. If a machine is authenticating, use API keys or mTLS. JWTs are a token format, not an authentication protocol — they can carry the result of either flow.

API Keys: Simple but Requires Care

API keys are opaque strings that identify and authenticate a caller. They are the simplest mechanism and perfectly appropriate for server-to-server communication.

Generation: keys should be cryptographically random and at least 256 bits.

javascript
const crypto = require('crypto');

// Generate API key
function generateApiKey() {
  return 'sk_' + crypto.randomBytes(32).toString('base64url');
}
// Result: "sk_ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnop"
// Prefix "sk_" helps identify key type in logs and grep

// Store hashed — never store plain API keys
const bcrypt = require('bcrypt');

async function createApiKey(userId, name) {
  const rawKey = generateApiKey();
  const keyHash = await bcrypt.hash(rawKey, 12);
  const keyPrefix = rawKey.slice(0, 10); // "sk_ABCDEFG" — for display

  await db.apiKeys.create({
    userId,
    name,
    keyHash,
    keyPrefix, // show last-used display in dashboard
    createdAt: new Date(),
    lastUsedAt: null,
    scopes: ['read'] // default minimal scope
  });

  return rawKey; // show ONCE to user, never again
}

Transmission: API keys belong in the Authorization header, never in the URL.

bash
# CORRECT
curl -H "Authorization: Bearer sk_your_api_key" https://api.yourapp.com/data

# WRONG — URL ends up in access logs, browser history, Referer headers
curl "https://api.yourapp.com/data?api_key=sk_your_api_key"
javascript
// Verification middleware
async function apiKeyAuth(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'Missing API key' });
  }

  const rawKey = authHeader.slice(7);
  const prefix = rawKey.slice(0, 10);

  // Look up by prefix (fast), then verify full hash
  const keyRecord = await db.apiKeys.findByPrefix(prefix);
  if (!keyRecord) return res.status(401).json({ error: 'Invalid API key' });

  const valid = await bcrypt.compare(rawKey, keyRecord.keyHash);
  if (!valid) return res.status(401).json({ error: 'Invalid API key' });

  // Update last used
  await db.apiKeys.update(keyRecord.id, { lastUsedAt: new Date() });

  req.apiKey = keyRecord;
  req.userId = keyRecord.userId;
  next();
}

Rate limiting per key: isolate misbehaving clients and enable usage-based billing.

javascript
const redis = require('redis');

async function rateLimitByApiKey(apiKeyId, limit, windowSeconds) {
  const key = `ratelimit:key:${apiKeyId}`;
  const count = await redis.incr(key);
  if (count === 1) await redis.expire(key, windowSeconds);
  
  const remaining = Math.max(0, limit - count);
  return { allowed: count <= limit, remaining, limit };
}

app.use(async (req, res, next) => {
  if (!req.apiKey) return next();
  
  const { allowed, remaining, limit } = await rateLimitByApiKey(
    req.apiKey.id, 1000, 3600 // 1000 requests/hour
  );
  
  res.setHeader('X-RateLimit-Limit', limit);
  res.setHeader('X-RateLimit-Remaining', remaining);
  
  if (!allowed) return res.status(429).json({ error: 'Rate limit exceeded' });
  next();
});

JWT for Stateless API Auth

JWTs are ideal when you need to propagate identity across microservices without database lookups on every request. The critical security rules (covered in detail in our JWT security guide):

  • Use RS256 (asymmetric), not HS256, for multi-service deployments.
  • Include exp, iat, iss, aud, and jti claims.
  • Keep access token lifetimes short (15 minutes).
  • Explicitly whitelist the algorithms option in your verification call.
  • Store in HttpOnly cookies for browser clients, not localStorage.
python
# FastAPI — JWT authentication dependency
from fastapi import Depends, HTTPException, status
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import jwt

security = HTTPBearer()

async def get_current_user(
    credentials: HTTPAuthorizationCredentials = Depends(security)
):
    token = credentials.credentials
    try:
        payload = jwt.decode(
            token,
            PUBLIC_KEY,
            algorithms=["RS256"],
            audience="api.yourapp.com",
            issuer="auth.yourapp.com"
        )
    except jwt.ExpiredSignatureError:
        raise HTTPException(status_code=401, detail="Token expired")
    except jwt.InvalidTokenError:
        raise HTTPException(status_code=401, detail="Invalid token")
    
    return payload

@app.get("/api/profile")
async def get_profile(user=Depends(get_current_user)):
    return {"userId": user["sub"], "email": user["email"]}

Scoping: Least Privilege for API Keys and Tokens

Every API key and token should carry the minimum scopes needed for its purpose. Scoping limits blast radius when credentials are compromised.

javascript
// Define available scopes
const SCOPES = {
  'read:users': 'Read user profiles',
  'write:users': 'Create and update users',
  'delete:users': 'Delete users',
  'read:billing': 'View billing information',
  'write:billing': 'Modify billing'
};

// Scope validation middleware
function requireScope(...requiredScopes) {
  return (req, res, next) => {
    const grantedScopes = req.apiKey?.scopes || req.token?.scope?.split(' ') || [];
    const hasAll = requiredScopes.every(s => grantedScopes.includes(s));
    
    if (!hasAll) {
      return res.status(403).json({
        error: 'Insufficient scope',
        required: requiredScopes,
        granted: grantedScopes
      });
    }
    next();
  };
}

app.get('/api/admin/users', requireScope('read:users', 'admin'), getAllUsers);
app.delete('/api/users/:id', requireScope('delete:users'), deleteUser);

Securing Credential Storage and Rotation

API keys in environment variables or secrets managers, never hardcoded:

javascript
// WRONG — hardcoded
const stripe = new Stripe('sk_live_hardcoded_key_here');

// WRONG — in version control
// config.js: module.exports = { apiKey: 'sk_live_...' }

// CORRECT — environment variable
const stripe = new Stripe(process.env.STRIPE_SECRET_KEY);

// CORRECT — secrets manager (AWS Secrets Manager, HashiCorp Vault)
const { SecretsManagerClient, GetSecretValueCommand } = require('@aws-sdk/client-secrets-manager');
const secretsClient = new SecretsManagerClient({ region: 'us-east-1' });

async function getStripeKey() {
  const response = await secretsClient.send(
    new GetSecretValueCommand({ SecretId: 'prod/stripe/api-key' })
  );
  return JSON.parse(response.SecretString).stripe_secret_key;
}

ZeriFlow scans your live site's response headers, checking for security misconfigurations that leave API endpoints exposed — including permissive CORS headers that allow cross-origin access to your API, missing authentication on documented endpoints, and transport security issues.


FAQ

### Q: Should API keys be hashed in the database? A: Yes, with bcrypt or Argon2. A database breach exposes all API keys if they are stored in plain text. Hashing means the attacker gets useless values. Use a prefix-based lookup (store and search by the first 8-10 chars) so you can find the record without scanning every hash.

### Q: Is it safe to include API keys in client-side JavaScript? A: Only for public (non-secret) keys, like publishable Stripe keys or Google Maps API keys. Secret keys — those that can trigger billing, access private data, or perform write operations — must never appear in client-side code. Proxy calls through your backend server.

### Q: How do I handle API key rotation without downtime? A: Support multiple active keys per account simultaneously. Notify the user to update, allow a grace period (e.g., 30 days), then revoke the old key. On the server side, your lookup queries all active keys for the account, so both work until the old one is revoked.

### Q: What is the difference between client credentials flow and API keys? A: OAuth Client Credentials flow is the OAuth-based equivalent of API keys — machine-to-machine auth. It produces short-lived JWTs rather than permanent keys, so there is nothing to rotate and no credential to leak. It is the preferred approach when your IdP supports it, as it integrates with your existing OAuth infrastructure.

### Q: How do I detect compromised API keys in production? A: Monitor for anomalies: requests from unusual IPs, volumes far above the customer's normal usage, or access to endpoints the key has never used. Many API platforms (Stripe, GitHub) also operate secret scanning programs — if a key appears in a public GitHub repo, they notify you and revoke it.


Conclusion

API authentication security comes down to: choose the right mechanism for the use case, generate keys with sufficient entropy, store them hashed, transmit them in headers not URLs, scope them to least privilege, rate-limit per key, and rotate regularly. Layer these controls together rather than relying on any one.

Run a free ZeriFlow scan → — no credit card required.

Ready to check your site?

Run a free security scan in 30 seconds.

Related articles

Keep reading