The agent authentication stack: API keys, OAuth, JWTs, and mTLS compared
39 million reasons to care about agent auth
In 2024, GitHub detected over 39 million leaked secrets across its platform — API keys, tokens, credentials sitting in public repositories. That number has been climbing year over year.
Now add AI agents to the picture. Agents need credentials to authenticate with your API. They store those credentials in config files, environment variables, and memory. They pass them in HTTP headers thousands of times per day. Every credential you issue to an agent is a credential that could leak, get replayed, or get stolen.
The authentication mechanism you choose determines your blast radius when (not if) something goes wrong.
This isn't an abstract security discussion. It's the practical difference between "we revoked a scoped token that expired in 30 minutes" and "we need to rotate every API key we've ever issued because one agent's config got committed to a public repo."
The four contenders
There are four serious options for authenticating AI agents making machine-to-machine (M2M) calls to your API:
- API keys — Static, opaque secrets
- OAuth 2.0 Client Credentials — Token exchange with scoped, short-lived access
- JWTs (signed assertions) — Self-contained, verifiable tokens with embedded claims
- mTLS — Mutual certificate-based authentication at the transport layer
Each has real trade-offs. Let's break them down with code, threat analysis, and the situations where each one actually makes sense.
1. API keys: the default everyone starts with
An API key is an opaque string — a shared secret between the client and the server. The client sends it with every request. The server looks it up in a database and decides whether to grant access.
How it works
Agent Your API
| |
|-- GET /api/data -------------->|
| x-api-key: sk_live_abc123 |
| |
|<-- 200 OK --------------------|
| { data } |
Implementation (Node.js)
// API key verification middleware
function authenticateApiKey(req, res, next) {
const apiKey = req.headers['x-api-key'];
if (!apiKey) {
return res.status(401).json({ error: 'Missing API key' });
}
// Hash the key before lookup (never store raw keys)
const keyHash = crypto
.createHash('sha256')
.update(apiKey)
.digest('hex');
const client = await db.clients.findOne({
apiKeyHash: keyHash,
status: 'active'
});
if (!client) {
return res.status(403).json({ error: 'Invalid API key' });
}
// Rate limit check
const usage = await rateLimiter.check(client.id);
if (usage.exceeded) {
return res.status(429).json({
error: 'Rate limit exceeded',
retryAfter: usage.resetAt
});
}
req.client = client;
next();
}
Implementation (Python / FastAPI)
from fastapi import FastAPI, Header, HTTPException
import hashlib
app = FastAPI()
async def verify_api_key(x_api_key: str = Header(...)):
key_hash = hashlib.sha256(x_api_key.encode()).hexdigest()
client = await db.clients.find_one({
"api_key_hash": key_hash,
"status": "active"
})
if not client:
raise HTTPException(status_code=403, detail="Invalid API key")
return client
@app.get("/api/data")
async def get_data(client=Depends(verify_api_key)):
return {"data": "...", "client_id": client["id"]}
Threat model
| Threat | Risk Level | Mitigation |
|---|---|---|
| Key leaked in source code | 🔴 Critical | Prefix keys (sk_live_, sk_test_) so scanners detect them |
| Key stolen from config/env | 🔴 Critical | No built-in expiry — compromised key works until manually revoked |
| Key replayed by attacker | 🟡 Medium | Rate limiting, IP allowlisting |
| Privilege escalation | 🟡 Medium | Most API keys have flat permissions — no scopes |
| Key shared between agents | 🟡 Medium | No way to distinguish which agent used a shared key |
The real problem
API keys are long-lived, unscoped, and bearer tokens — whoever holds the key has access, period. There's no expiry, no built-in scope mechanism, and no standard way to distinguish between a legitimate agent and an attacker who found the key in a .env file.
Stripe does API keys well: they prefix keys (sk_live_, pk_test_), support restricted keys with granular permissions, and maintain separate test/live environments. But that level of API key engineering is rare. Most implementations are just a random string in a database.
When API keys actually make sense
- Internal services behind a VPN or service mesh where the network is the trust boundary
- Developer onboarding — let people try your API in 30 seconds before graduating to OAuth
- Low-value read-only endpoints where a leaked key has minimal blast radius
- Webhook verification using HMAC signatures (technically a derived use of shared secrets)
2. OAuth 2.0 Client Credentials: the production standard
The OAuth 2.0 Client Credentials Grant (RFC 6749 §4.4) was literally designed for machine-to-machine authentication. No browser. No human. The client exchanges its credentials at a token endpoint for a short-lived, scoped access token.
How it works
Agent Auth Server Your API
| | |
|-- POST /oauth/token ------>| |
| grant_type=client_creds | |
| client_id=xxx | |
| client_secret=yyy | |
| scope=read:data | |
| | |
|<-- { access_token, -------| |
| expires_in: 3600 } | |
| | |
|-- GET /api/data ------------------------------>|
| Authorization: Bearer <token> |
| | |
|<-- 200 OK ------------------------------------|
Implementation: token endpoint (Node.js)
app.post('/oauth/token', async (req, res) => {
const { grant_type, client_id, client_secret, scope } = req.body;
if (grant_type !== 'client_credentials') {
return res.status(400).json({ error: 'unsupported_grant_type' });
}
// Validate client credentials
const client = await db.clients.findOne({ clientId: client_id });
if (!client) {
return res.status(401).json({ error: 'invalid_client' });
}
const secretValid = await bcrypt.compare(client_secret, client.secretHash);
if (!secretValid) {
return res.status(401).json({ error: 'invalid_client' });
}
// Validate requested scopes against client's allowed scopes
const requestedScopes = (scope || '').split(' ').filter(Boolean);
const allowedScopes = client.allowedScopes || [];
const grantedScopes = requestedScopes.filter(s => allowedScopes.includes(s));
if (requestedScopes.length > 0 && grantedScopes.length === 0) {
return res.status(400).json({ error: 'invalid_scope' });
}
// Issue a short-lived JWT access token
const accessToken = jwt.sign(
{
sub: client.clientId,
client_id: client.clientId,
scope: grantedScopes.join(' '),
type: 'access_token',
},
process.env.JWT_SIGNING_KEY,
{
algorithm: 'RS256',
expiresIn: '1h',
issuer: 'https://auth.yourapp.com',
audience: 'https://api.yourapp.com',
}
);
res.json({
access_token: accessToken,
token_type: 'Bearer',
expires_in: 3600,
scope: grantedScopes.join(' '),
});
});
Implementation: token verification middleware
const jwksClient = require('jwks-rsa');
const client = jwksClient({
jwksUri: 'https://auth.yourapp.com/.well-known/jwks.json',
cache: true,
rateLimit: true,
});
function getSigningKey(header, callback) {
client.getSigningKey(header.kid, (err, key) => {
if (err) return callback(err);
callback(null, key.getPublicKey());
});
}
function requireScope(...requiredScopes) {
return (req, res, next) => {
const authHeader = req.headers.authorization;
if (!authHeader?.startsWith('Bearer ')) {
return res.status(401).json({ error: 'Missing bearer token' });
}
const token = authHeader.slice(7);
jwt.verify(token, getSigningKey, {
algorithms: ['RS256'],
issuer: 'https://auth.yourapp.com',
audience: 'https://api.yourapp.com',
}, (err, decoded) => {
if (err) {
return res.status(401).json({ error: 'Invalid token' });
}
// Check scopes
const tokenScopes = (decoded.scope || '').split(' ');
const hasScope = requiredScopes.some(s => tokenScopes.includes(s));
if (!hasScope) {
return res.status(403).json({ error: 'Insufficient scope' });
}
req.client = decoded;
next();
});
};
}
// Usage
app.get('/api/data', requireScope('read:data'), (req, res) => {
// req.client contains decoded token claims
res.json({ data: '...' });
});
Threat model
| Threat | Risk Level | Mitigation |
|---|---|---|
| Client secret leaked | 🟡 Medium | Secret alone isn't enough — attacker still needs to exchange it. Rotate the secret and all issued tokens expire naturally. |
| Access token stolen | 🟢 Low | Token expires in minutes/hours. Blast radius is time-bounded. |
| Token replayed | 🟢 Low | Short expiry + scope limits = limited damage window |
| Privilege escalation | 🟢 Low | Scopes are baked into the token, validated on every request |
| Scope misconfiguration | 🟡 Medium | Overly broad scopes undo most security benefits |
Why this is the default for production
OAuth Client Credentials gives you everything API keys don't:
- Short-lived tokens: If a token leaks, it's dead in an hour (or less — you control expiry)
- Scoped access: Agents only get the permissions they request and are allowed to have
- Standard protocol: Every auth library, every language, every auth provider supports it
- Auditability: Token issuance is logged. You know which client authenticated when.
- Rotation without downtime: Rotate the client secret → old tokens still work until they expire → new tokens use the new secret
OAuth 2.1 (draft spec) tightens this further by removing the implicit and password grant types entirely, mandating PKCE for public clients, and requiring refresh token rotation. Anthropic has already adopted OAuth 2.1 as the authentication standard for the Model Context Protocol (MCP).
3. JWTs as bearer assertions: beyond access tokens
JWTs appear inside the OAuth flow (as access tokens), but they can also serve as the authentication credential itself — replacing client_id + client_secret with a signed assertion. This is the JWT Bearer Grant (RFC 7523).
How it works
Instead of sending a client secret, the agent signs a JWT with its private key and sends that as proof of identity:
Agent (holds private key) Auth Server (holds public key)
| |
|-- POST /oauth/token ------------>|
| grant_type=urn:ietf:params: |
| oauth:grant-type:jwt-bearer |
| assertion=<signed-jwt> |
| scope=read:data |
| |
|<-- { access_token } -------------|
Creating the assertion (Node.js)
const crypto = require('crypto');
const jwt = require('jsonwebtoken');
const fs = require('fs');
function createClientAssertion(clientId, tokenEndpoint) {
// Agent's private key — never leaves the agent's environment
const privateKey = fs.readFileSync('./agent-private-key.pem');
const now = Math.floor(Date.now() / 1000);
return jwt.sign(
{
iss: clientId, // The agent's client ID
sub: clientId, // Same — the agent is authenticating itself
aud: tokenEndpoint, // The auth server's token endpoint
iat: now,
exp: now + 300, // 5 minute validity
jti: crypto.randomUUID() // Unique ID to prevent replay
},
privateKey,
{ algorithm: 'RS256' }
);
}
// Token request using JWT assertion
async function getAccessToken(clientId) {
const assertion = createClientAssertion(
clientId,
'https://auth.yourapp.com/oauth/token'
);
const response = await fetch('https://auth.yourapp.com/oauth/token', {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'urn:ietf:params:oauth:grant-type:jwt-bearer',
assertion: assertion,
scope: 'read:data write:data',
}),
});
return response.json();
}
Verifying assertions on the server
app.post('/oauth/token', async (req, res) => {
const { grant_type, assertion, scope } = req.body;
if (grant_type !== 'urn:ietf:params:oauth:grant-type:jwt-bearer') {
return res.status(400).json({ error: 'unsupported_grant_type' });
}
// Decode without verifying first to extract the issuer
const unverified = jwt.decode(assertion, { complete: true });
if (!unverified) {
return res.status(400).json({ error: 'invalid_assertion' });
}
// Look up the client's registered public key
const client = await db.clients.findOne({
clientId: unverified.payload.iss
});
if (!client?.publicKey) {
return res.status(401).json({ error: 'invalid_client' });
}
try {
const decoded = jwt.verify(assertion, client.publicKey, {
algorithms: ['RS256'], // CRITICAL: restrict algorithms
audience: 'https://auth.yourapp.com/oauth/token',
clockTolerance: 30,
});
// Check jti hasn't been used before (replay protection)
const used = await db.usedJtis.findOne({ jti: decoded.jti });
if (used) {
return res.status(400).json({ error: 'assertion_replay' });
}
await db.usedJtis.insertOne({
jti: decoded.jti,
exp: new Date(decoded.exp * 1000)
});
// Issue access token (same as Client Credentials flow)
const accessToken = issueAccessToken(client, scope);
res.json({ access_token: accessToken, token_type: 'Bearer' });
} catch (err) {
return res.status(401).json({ error: 'invalid_assertion' });
}
});
Why JWT assertions matter for agents
The key advantage: the secret never travels over the network. With Client Credentials, you send client_secret to the token endpoint on every token request. With JWT assertions, you send a signed proof that you hold the private key — but the key itself stays on the agent's machine.
This is the same principle behind SSH keys: you prove identity with a signature, not by transmitting the secret.
JWT security pitfalls (real attacks, not theoretical)
JWTs have a well-documented history of implementation vulnerabilities:
Algorithm confusion attack: If your verification code accepts the algorithm from the JWT header without restriction, an attacker can:
- Take a JWT signed with RS256 (asymmetric)
- Change the header to HS256 (symmetric)
- Sign it using the server's public key as the HMAC secret
- The server verifies using the public key as an HMAC secret — and it passes
// ❌ VULNERABLE — accepts algorithm from token header
jwt.verify(token, publicKey);
// ✅ SECURE — explicitly restricts allowed algorithms
jwt.verify(token, publicKey, { algorithms: ['RS256'] });
none algorithm attack: Some libraries accept alg: "none", which means the token isn't signed at all:
// ❌ An unsigned JWT that some libraries will accept
eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJzdWIiOiIxMjM0NTY3ODkwIn0.
Missing kid validation: If you use a Key ID (kid) to select the verification key, make sure it's validated. Attackers can inject SQL or path traversal via the kid field if it's used in a database query or file path unsanitized.
These aren't hypothetical. CVE-2025-9312, disclosed in November 2025, showed a real mTLS authentication bypass in WSO2 where the system accepted requests without valid client certificates when mTLS was enabled. The point: protocol-level security is only as good as the implementation.
Threat model
| Threat | Risk Level | Mitigation |
|---|---|---|
| Private key leaked | 🟡 Medium | Key only exists on agent's machine. No network transmission. |
| Algorithm confusion | 🔴 Critical (if vulnerable) | ALWAYS restrict algorithms in verification |
| Assertion replay | 🟡 Medium | jti claim + server-side tracking |
| Token forgery | 🟢 Low (with RS256) | Asymmetric crypto — forging requires the private key |
4. mTLS: transport-layer identity
Mutual TLS (mTLS) pushes authentication down to the transport layer. Both the client and server present X.509 certificates during the TLS handshake. The server verifies the client's certificate against a trusted Certificate Authority (CA), and the client verifies the server's. Authentication happens before any HTTP request is even sent.
How it works
Agent (client cert) Your API (server cert)
| |
|-- TLS ClientHello -------------->|
|<-- TLS ServerHello + CertReq ----|
|-- Client Certificate ----------->|
|-- TLS Finished ----------------->|
|<-- TLS Finished -----------------|
| |
| (Encrypted channel established) |
| |
|-- GET /api/data ----------------->|
|<-- 200 OK ------------------------|
Server setup (Node.js)
const https = require('https');
const fs = require('fs');
const express = require('express');
const app = express();
// mTLS server configuration
const server = https.createServer(
{
// Server's own certificate and key
cert: fs.readFileSync('./server-cert.pem'),
key: fs.readFileSync('./server-key.pem'),
// CA certificate(s) that issued client certs
// ONLY clients with certs from this CA are accepted
ca: fs.readFileSync('./client-ca-cert.pem'),
// Require client certificates
requestCert: true,
rejectUnauthorized: true,
},
app
);
// Extract client identity from the certificate
app.use((req, res, next) => {
const cert = req.socket.getPeerCertificate();
if (!cert || !cert.subject) {
return res.status(403).json({ error: 'No client certificate' });
}
// The Common Name (CN) or Subject Alternative Name (SAN)
// identifies the agent
req.clientIdentity = {
commonName: cert.subject.CN,
organization: cert.subject.O,
fingerprint: cert.fingerprint256,
validFrom: cert.valid_from,
validTo: cert.valid_to,
};
next();
});
app.get('/api/data', (req, res) => {
console.log(`Request from: ${req.clientIdentity.commonName}`);
res.json({ data: '...' });
});
server.listen(443);
Client setup (Python)
import httpx
# Agent makes requests with its client certificate
client = httpx.Client(
cert=("./agent-cert.pem", "./agent-key.pem"),
verify="./server-ca-cert.pem", # Verify server's certificate
)
response = client.get("https://api.yourapp.com/data")
print(response.json())
Certificate management: the real cost
mTLS is the strongest authentication mechanism on this list. It's also the hardest to operate. Here's what you're signing up for:
Certificate lifecycle management:
- Generate a CA (or use an intermediate CA)
- Issue client certificates for each agent
- Distribute certificates securely to agents
- Track certificate expiry dates
- Rotate certificates before they expire
- Revoke compromised certificates via CRL or OCSP
What this looks like at scale:
# Generate a client certificate for an agent (simplified)
# In production, use a proper PKI tool like step-ca, Vault, or SPIFFE/SPIRE
# 1. Generate agent's private key
openssl genrsa -out agent-key.pem 4096
# 2. Create a Certificate Signing Request
openssl req -new -key agent-key.pem -out agent-csr.pem \
-subj "/CN=agent-acme-corp/O=AcmeCorp/OU=agents"
# 3. Sign it with your CA
openssl x509 -req -in agent-csr.pem \
-CA ca-cert.pem -CAkey ca-key.pem \
-CAcreateserial -out agent-cert.pem \
-days 90 -sha256
# 4. Distribute agent-cert.pem and agent-key.pem to the agent
# 5. Set a reminder to rotate in 60 days
# 6. Repeat for every agent
For organizations running large-scale mTLS, projects like SPIFFE/SPIRE automate workload identity issuance. SPIRE agents run on each node, automatically request short-lived certificates (SVIDs) from the SPIRE server, and rotate them without manual intervention. It's powerful infrastructure — but it's infrastructure you need to build and maintain.
Threat model
| Threat | Risk Level | Mitigation |
|---|---|---|
| Private key stolen from agent | 🟡 Medium | Certificate has a defined validity period. Revoke via CRL/OCSP. |
| Certificate replay | 🟢 Low | TLS handshake includes fresh nonces — can't replay a handshake |
| CA compromise | 🔴 Critical | All certificates become untrustworthy. Use short-lived certs + automation. |
| Certificate expiry outage | 🟡 Medium | Automated rotation via SPIFFE/SPIRE or Vault PKI |
| Man-in-the-middle | 🟢 Very Low | Both sides authenticate each other — MITM requires compromising both CAs |
The comparison matrix
Here's the full side-by-side, evaluated across dimensions that matter for AI agent access:
Free Tool
How agent-ready is your website?
Run a free scan to see how AI agents experience your signup flow, robots.txt, API docs, and LLM visibility.
Run a free scan →| Dimension | API Keys | OAuth Client Creds | JWT Assertions | mTLS |
|---|---|---|---|---|
| Setup complexity | 5 min | 2-4 hours | 4-8 hours | 1-3 days |
| Secret travels on wire | Every request | Only at token exchange | Never (signed proof) | Never (TLS handshake) |
| Token lifetime | ♾️ (until revoked) | Minutes to hours | Assertion: minutes; access token: hours | Cert validity: days to months |
| Scope/permission model | Usually none | Built-in scopes | Built-in scopes | Typically identity-only (authz is separate) |
| Blast radius of leak | Full access, indefinitely | Limited by token expiry + scope | Private key leak: can mint assertions | Cert leak: bounded by validity + revocation |
| Revocation speed | Instant (delete from DB) | Wait for token expiry (or use introspection) | Instant (remove public key) | CRL/OCSP propagation delay |
| Standards compliance | No standard | RFC 6749, RFC 6750, OAuth 2.1 | RFC 7523 | RFC 8705 (OAuth mTLS), X.509 |
| Agent implementation effort | Trivial | Low (one HTTP call for token) | Medium (crypto + signing) | Medium-High (cert management) |
| Works through proxies/CDNs | ✅ Yes | ✅ Yes | ✅ Yes | ⚠️ Often terminated at edge |
| Multi-tenant support | Manual | Native (client per tenant) | Native | Possible (via cert attributes) |
The decision framework
Don't start from "what's most secure?" Start from "what's appropriate for my use case?"
Start here: API keys
Use API keys when:
- You're building an MVP and need agent access in days, not weeks
- The agent is accessing read-only, low-sensitivity data
- You're behind a service mesh or VPN where the network provides the trust boundary
- It's a developer sandbox / testing environment
Graduate when: you have more than ~10 external agent consumers, handle sensitive data, or need per-agent permission scoping.
Default choice: OAuth Client Credentials
Use OAuth Client Credentials when:
- You're running a production API with external agent consumers
- You need scoped access (read vs. write, different resource types)
- You want standard-compliant auth that every agent framework already supports
- You need audit trails of which agents accessed what, when
- You want time-bounded access without manual revocation
This is the right answer for 80% of SaaS companies. It's the reason Anthropic chose OAuth 2.1 for MCP, and it's what we recommend as the baseline in our agent-readiness benchmark.
When to add JWT assertions
Layer JWT assertions on top of OAuth when:
- You can't afford to send client secrets over the network (regulated industries)
- Agents run in environments where secrets in environment variables are a risk
- You need non-repudiation — cryptographic proof of which agent authenticated
- Your clients (agent operators) manage their own key pairs
Common in: financial services, healthcare APIs, enterprise integrations where client secret rotation is operationally expensive.
When to use mTLS
Use mTLS when:
- You're in a zero-trust network environment
- Compliance requirements mandate transport-layer authentication (PCI DSS, SOC 2 with enhanced controls)
- You operate a service mesh where mTLS is already the standard (Istio, Linkerd)
- You need the strongest possible authentication guarantee and have the infrastructure team to operate it
Be honest about operational cost. mTLS is the most secure option on this list. It's also the one most likely to cause an outage at 3 AM because a certificate expired and nobody noticed. If you don't have automated certificate management, you'll spend more time fighting mTLS than benefiting from it.
Hybrid patterns: what production actually looks like
In practice, most systems don't use just one mechanism. Here are patterns we see in companies with mature agent authentication:
Pattern 1: API keys for onboarding, OAuth for production
Developer signs up → gets test API key → starts building
↓
Registers OAuth client for production
↓
Production agent uses Client Credentials flow
Stripe, Twilio, and most developer-first platforms follow this pattern. The API key gets you started in 30 seconds. OAuth is what you ship.
Pattern 2: OAuth + JWT assertions for high-security flows
Normal agent operations → OAuth Client Credentials (client_secret)
↓
Sensitive operations → OAuth with JWT assertion (signed proof)
(billing, PII access)
Step up authentication for operations that touch sensitive data. The agent uses standard Client Credentials for read operations and switches to JWT assertions for writes or PII access.
Pattern 3: mTLS at the edge + OAuth for app-layer authz
Agent → mTLS terminates at load balancer/API gateway
↓
Certificate CN extracted, passed as header
↓
OAuth token verified for scope/permissions
This is common in Kubernetes environments with service meshes. mTLS handles identity at the transport layer. OAuth handles authorization at the application layer. You get the best of both.
What this means for agent readiness
If you're building a SaaS product that AI agents will access, here's the minimum viable authentication stack:
-
Support OAuth 2.0 Client Credentials — this is table stakes. Publish your token endpoint, document your scopes, and make it work with standard libraries.
-
Offer a self-service client registration flow — agents (and their operators) should be able to create credentials without emailing your sales team.
-
Publish a discovery document —
/.well-known/openid-configurationor/.well-known/oauth-authorization-serverso agents can find your auth endpoints programmatically. -
Set sane token lifetimes — 1 hour is a good default. Short enough to limit blast radius, long enough that agents aren't hammering your token endpoint.
-
Design meaningful scopes —
read:contacts write:contactsis useful.adminis not.
Your agent-readiness score reflects these practices. Companies that support OAuth with proper scopes and self-service registration consistently score 15-20 points higher than those offering only API keys.
The choice of auth mechanism isn't permanent. Start with API keys, graduate to OAuth Client Credentials, and layer on JWT assertions or mTLS when your security requirements demand it. The important thing is building the right foundation — and for most SaaS companies in 2026, that foundation is OAuth.
Want to see how your authentication stack scores? Run your domain through the AgentGate benchmark and see where you stand.
Get Started
Ready to make your product agent-accessible?
Add a few lines of code and let AI agents discover, request access, and get real credentials — with human oversight built in.
Get started with Anon →