API authentication schemes compared: Bearer, Basic, API keys, mTLS, and HMAC

There is no universal best API authentication scheme. Each has a different threat model, different operational cost, and different appropriate use cases. Using Bearer tokens for an industrial control system API and HMAC signing for a simple developer API are both category errors. Understanding why each scheme exists and what it protects against lets you choose the right one.

HTTP Basic authentication

Basic authentication sends a base64-encoded username and password in the Authorization header on every request. It is the simplest possible scheme and the most dangerous to use incorrectly. The credentials are transmitted in plaintext (base64 is not encryption) and logged by virtually every proxy, load balancer, and web server if you are not careful.

Basic auth is appropriate in exactly one current use case: OAuth 2.0 client authentication at the token endpoint. Clients authenticating with a client ID and secret use Basic auth because the OAuth spec explicitly supports it and the token endpoint is a non-interactive server-to-server call over HTTPS. For anything else, do not use Basic auth.

// The only acceptable use of Basic auth — OAuth client authentication
POST /oauth/token HTTP/1.1
Authorization: Basic czZCaGRSa3F0MzpnWDFmQmF0M2JW
Content-Type: application/x-www-form-urlencoded

grant_type=client_credentials&scope=read:data

Bearer tokens

Bearer token authentication uses the value of the token as a credential — whoever holds the token can use it. The token is typically a JWT or an opaque reference string sent in the Authorization: Bearer header. This is the dominant scheme for OAuth 2.0-protected APIs.

The threat model: bearer tokens are susceptible to theft in transit (mitigated by TLS) and at rest (a token stored in localStorage or a log file). If stolen, the attacker can use the token until it expires. Mitigation comes from short token lifetimes (15 minutes), token binding (binding the token to the client's TLS certificate), and revocation.

// Bearer token validation middleware
async function validateBearerToken(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({ error: 'missing_token' });
  }

  const token = authHeader.slice(7);

  try {
    const payload = await verifyJwt(token, {
      algorithms: ['RS256'],
      audience: process.env.API_AUDIENCE,
      issuer: process.env.ISSUER_URL
    });
    req.auth = payload;
    next();
  } catch (err) {
    if (err.name === 'TokenExpiredError') {
      return res.status(401).json({ error: 'token_expired' });
    }
    return res.status(401).json({ error: 'invalid_token' });
  }
}

API keys

API keys are long-lived bearer credentials, typically a high-entropy random string, issued to developers for programmatic access. They are simpler than OAuth flows — no token exchange, no expiry management — but they are long-lived by design, which increases the risk window if compromised.

Good API key design: prefix the key with a product identifier (bast_) so it can be identified in logs and scanned for in source control. Store only a hash server-side. Support multiple keys per user. Provide a key rotation mechanism. Emit an event when a key is first used from a new IP.

// API key issuance — store only the hash
async function issueApiKey(userId: string, name: string): Promise<{raw: string, id: string}> {
  // Format: prefix + 32 bytes of entropy, base58 encoded
  const rawKey = 'bast_' + base58.encode(crypto.randomBytes(32));
  const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');

  const keyRecord = await db.apiKeys.insert({
    user_id: userId,
    name,
    key_hash: keyHash,
    key_prefix: rawKey.slice(0, 12),  // for display
    created_at: new Date(),
    last_used_at: null
  });

  // Return raw key once — never stored in plaintext
  return { raw: rawKey, id: keyRecord.id };
}

// API key validation
async function validateApiKey(rawKey: string): Promise<ApiKeyRecord> {
  if (!rawKey.startsWith('bast_')) {
    throw new InvalidKeyError();
  }

  const keyHash = crypto.createHash('sha256').update(rawKey).digest('hex');
  const record = await db.apiKeys.findByHash(keyHash);

  if (!record || record.revoked) throw new InvalidKeyError();

  // Update last used timestamp asynchronously
  db.apiKeys.update(record.id, { last_used_at: new Date() }).catch(() => {});

  return record;
}

Mutual TLS (mTLS)

In standard TLS, only the server presents a certificate. In mutual TLS, the client also presents a certificate that the server validates. This provides strong client authentication at the transport layer — the client's identity is proven by possession of the private key corresponding to the certificate, which was signed by a trusted CA.

mTLS is appropriate for service-to-service communication in zero-trust environments, for APIs where client certificate management is feasible (typically machine-to-machine with corporate PKI), and for high-security financial APIs. It is not practical for developer-facing public APIs because certificate provisioning and rotation requires infrastructure that most developers do not have.

// Node.js HTTPS server requiring client certificates
const https = require('https');
const fs = require('fs');
const tls = require('tls');

const server = https.createServer({
  key: fs.readFileSync('server-key.pem'),
  cert: fs.readFileSync('server-cert.pem'),
  ca: fs.readFileSync('trusted-client-ca.pem'),
  requestCert: true,
  rejectUnauthorized: true  // reject clients without valid certs
}, app);

// Extract client identity from certificate
app.use((req, res, next) => {
  const cert = req.socket.getPeerCertificate();
  if (!cert || !Object.keys(cert).length) {
    return res.status(401).json({ error: 'client certificate required' });
  }

  // cert.subject.CN contains the client identifier
  req.clientId = cert.subject.CN;
  req.certFingerprint = cert.fingerprint256;
  next();
});

HMAC request signing

HMAC signing protects the request body against tampering in transit and provides replay protection through timestamps and nonces. The client computes an HMAC over a canonical representation of the request (method, path, headers, body hash, timestamp) and includes the signature in a header. The server recomputes the same HMAC and compares.

This is used by AWS (AWS Signature Version 4), Stripe webhooks, and payment APIs where request integrity and non-repudiation matter. The advantage over bearer tokens: even if an attacker intercepts the request, they cannot modify the body without the signature becoming invalid, and they cannot replay the request after the timestamp window.

// HMAC request signing (AWS-style)
function signRequest(
  method: string,
  path: string,
  body: string,
  apiKey: string,
  secret: string
): Record<string, string> {
  const timestamp = new Date().toISOString().replace(/[:\-]|\.\d{3}/g, '');
  const bodyHash = crypto.createHash('sha256').update(body).digest('hex');
  const nonce = crypto.randomBytes(16).toString('hex');

  const stringToSign = [
    method.toUpperCase(),
    path,
    timestamp,
    nonce,
    bodyHash
  ].join('\n');

  const signature = crypto
    .createHmac('sha256', secret)
    .update(stringToSign)
    .digest('hex');

  return {
    'X-Api-Key': apiKey,
    'X-Timestamp': timestamp,
    'X-Nonce': nonce,
    'X-Signature': signature,
    'X-Body-Hash': bodyHash
  };
}

// Server-side validation
async function validateHmacSignature(req: Request): Promise<void> {
  const { 'x-api-key': apiKey, 'x-timestamp': timestamp,
          'x-nonce': nonce, 'x-signature': signature,
          'x-body-hash': bodyHash } = req.headers;

  // Check timestamp freshness (reject requests older than 5 minutes)
  const requestTime = parseTimestamp(timestamp as string);
  if (Math.abs(Date.now() - requestTime) > 5 * 60 * 1000) {
    throw new Error('Request timestamp too old');
  }

  // Check nonce for replay prevention
  const nonceKey = `nonce:${nonce}`;
  const seen = await redis.set(nonceKey, '1', 'NX', 'EX', 300);
  if (!seen) throw new Error('Replay detected');

  const secret = await getApiKeySecret(apiKey as string);
  const expectedString = [
    req.method.toUpperCase(),
    req.path,
    timestamp,
    nonce,
    bodyHash
  ].join('\n');

  const expected = crypto.createHmac('sha256', secret)
    .update(expectedString).digest('hex');

  if (!timingSafeEqual(Buffer.from(signature as string), Buffer.from(expected))) {
    throw new Error('Invalid signature');
  }
}

Choosing the right scheme

Bearer tokens with short TTLs are the right choice for user-delegated access to APIs where the user has authenticated via OAuth. API keys are the right choice for developer integrations where the simplicity of a static credential outweighs the long-lived risk, and where programmatic rotation is straightforward. HMAC signing is appropriate when request integrity guarantees matter, such as financial transactions or webhook delivery. mTLS is appropriate for internal service-to-service communication in environments with PKI infrastructure. Never use Basic auth outside of OAuth token endpoint client authentication.

← Back to blog Try Bastionary free →