← Back to Blog
Developer Experience12 min readFebruary 27, 2026

Implementing OAuth for AI agents: a practical guide with code

A
Anon Team

Your OAuth flow wasn't built for agents

Here's what happens when an AI agent tries to use your OAuth flow today:

  1. Agent navigates to your /authorize endpoint
  2. Gets redirected to a login page
  3. Needs to fill in a username and password
  4. Possibly solves a CAPTCHA
  5. Clicks "Authorize" on a consent screen
  6. Gets redirected back with a code
  7. Exchanges the code for a token

Steps 2 through 6 require a browser, a human, or both. An AI agent can technically puppet a browser through this — but it's slow, brittle, and breaks every time you change your UI.

In our benchmark of 163 SaaS companies, 77.3% supported OAuth. Their average agent-readiness score was 63.6 out of 100. Having OAuth helps. But it's not enough, because the Authorization Code flow was designed for humans in browsers, not for agents making API calls.

This guide shows you how to build authentication that agents can actually use — with working code, real architecture decisions, and patterns you can ship this week.

The authentication stack: what to use when

Before writing any code, you need to pick the right authentication mechanism. Here's a practical comparison for agent use cases:

Mechanism Agent-Friendly? Security Complexity Best For
Static API Keys ✅ Simple ⚠️ Low Low Internal tools, prototyping
OAuth 2.0 Client Credentials ✅ Yes ✅ High Medium Production M2M, third-party agents
JWT Bearer Assertions ✅ Yes ✅ High High Enterprise, service accounts
mTLS ✅ Yes ✅ Very High Very High Zero-trust, regulated industries

For most SaaS companies, OAuth 2.0 Client Credentials is the right answer. It's standards-compliant, well-supported by every major auth provider, and agents already know how to use it. API keys are fine for developer onboarding, but they're static, unscopable, and a security liability at scale.

Let's build it.

Step 1: Set up the Client Credentials flow

The OAuth 2.0 Client Credentials Grant (RFC 6749 §4.4) is purpose-built for machine-to-machine authentication. No browser. No user interaction. Just credentials in, token out.

The flow:

Agent                        Your Auth Server
  |                                |
  |-- POST /oauth/token ---------->|
  |   grant_type=client_credentials|
  |   client_id=xxx               |
  |   client_secret=yyy           |
  |   scope=read:data write:data  |
  |                                |
  |<-- 200 OK --------------------|
  |   { access_token, expires_in } |
  |                                |
  |-- GET /api/resource ---------->|
  |   Authorization: Bearer token  |
  |                                |

Token endpoint implementation (Node.js / Express)

const express = require('express');
const jwt = require('jsonwebtoken');
const crypto = require('crypto');

const app = express();
app.use(express.urlencoded({ extended: true }));

// In production, store these in a database with hashed secrets
const clients = new Map([
  ['agent_cli_abc123', {
    secretHash: hashSecret('sk_live_xxxxxx'),
    allowedScopes: ['read:projects', 'write:projects', 'read:analytics'],
    rateLimitTier: 'standard',
    agentMetadata: {
      registeredAt: '2026-02-15T00:00:00Z',
      agentType: 'autonomous',  // 'autonomous' | 'supervised' | 'tool'
      owner: 'user_12345'
    }
  }]
]);

app.post('/oauth/token', async (req, res) => {
  const { grant_type, client_id, client_secret, scope } = req.body;

  // 1. Validate grant type
  if (grant_type !== 'client_credentials') {
    return res.status(400).json({
      error: 'unsupported_grant_type',
      error_description: 'Only client_credentials is supported for M2M auth'
    });
  }

  // 2. Authenticate the client
  const client = clients.get(client_id);
  if (!client || !verifySecret(client_secret, client.secretHash)) {
    return res.status(401).json({
      error: 'invalid_client',
      error_description: 'Client authentication failed'
    });
  }

  // 3. Validate requested scopes
  const requestedScopes = scope ? scope.split(' ') : [];
  const grantedScopes = requestedScopes.filter(s =>
    client.allowedScopes.includes(s)
  );

  if (requestedScopes.length > 0 && grantedScopes.length === 0) {
    return res.status(400).json({
      error: 'invalid_scope',
      error_description: 'None of the requested scopes are allowed'
    });
  }

  // 4. Issue the token
  const token = jwt.sign({
    sub: client_id,
    scope: grantedScopes.join(' '),
    agent_type: client.agentMetadata.agentType,
    owner: client.agentMetadata.owner,
    jti: crypto.randomUUID(),  // Unique token ID for revocation
  }, process.env.JWT_SECRET, {
    expiresIn: '1h',
    issuer: 'https://api.yourcompany.com',
    audience: 'https://api.yourcompany.com'
  });

  res.json({
    access_token: token,
    token_type: 'Bearer',
    expires_in: 3600,
    scope: grantedScopes.join(' ')
  });
});

function hashSecret(secret) {
  return crypto.createHash('sha256').update(secret).digest('hex');
}

function verifySecret(input, hash) {
  return crypto.timingSafeEqual(
    Buffer.from(hashSecret(input)),
    Buffer.from(hash)
  );
}

The same flow in Python (FastAPI)

from fastapi import FastAPI, Form, HTTPException
from datetime import datetime, timedelta, timezone
import jwt
import hashlib
import hmac
import secrets

app = FastAPI()

# Client registry (use a database in production)
CLIENTS = {
    "agent_cli_abc123": {
        "secret_hash": hashlib.sha256(b"sk_live_xxxxxx").hexdigest(),
        "allowed_scopes": ["read:projects", "write:projects", "read:analytics"],
        "agent_type": "autonomous",
        "owner": "user_12345",
    }
}

JWT_SECRET = "your-signing-key"  # Use RS256 + JWKS in production

@app.post("/oauth/token")
async def token(
    grant_type: str = Form(...),
    client_id: str = Form(...),
    client_secret: str = Form(...),
    scope: str = Form(""),
):
    if grant_type != "client_credentials":
        raise HTTPException(400, detail={
            "error": "unsupported_grant_type"
        })

    client = CLIENTS.get(client_id)
    if not client:
        raise HTTPException(401, detail={"error": "invalid_client"})

    # Constant-time comparison
    input_hash = hashlib.sha256(client_secret.encode()).hexdigest()
    if not hmac.compare_digest(input_hash, client["secret_hash"]):
        raise HTTPException(401, detail={"error": "invalid_client"})

    # Scope validation
    requested = scope.split() if scope else []
    granted = [s for s in requested if s in client["allowed_scopes"]]

    now = datetime.now(timezone.utc)
    payload = {
        "sub": client_id,
        "scope": " ".join(granted),
        "agent_type": client["agent_type"],
        "owner": client["owner"],
        "jti": secrets.token_hex(16),
        "iat": now,
        "exp": now + timedelta(hours=1),
        "iss": "https://api.yourcompany.com",
        "aud": "https://api.yourcompany.com",
    }

    access_token = jwt.encode(payload, JWT_SECRET, algorithm="HS256")

    return {
        "access_token": access_token,
        "token_type": "Bearer",
        "expires_in": 3600,
        "scope": " ".join(granted),
    }

Step 2: Design scopes for agent access patterns

Agents don't use your product the way humans do. Humans browse dashboards, click through menus, and make one-off decisions. Agents execute workflows — often repetitive, often at scale, and often touching multiple resources in sequence.

Your scope design should reflect this:

Bad: coarse-grained scopes

scopes: ["admin", "user", "readonly"]

A scope called admin gives an agent the keys to the kingdom. If the agent is compromised, the blast radius is your entire platform. If the agent only needs to read analytics data, why does it have permission to delete users?

Good: resource-action scopes

scopes: [
  "read:projects",
  "write:projects",
  "read:analytics",
  "write:webhooks",
  "read:billing",
  "execute:deployments"
]

This pattern follows {action}:{resource} and mirrors how developers already think about permissions. An agent that needs analytics data requests read:analytics and nothing else.

Better: scopes with intent

scopes: [
  "read:projects",
  "write:projects",
  "read:analytics",
  "agent:batch-operations",     // Allows bulk API calls
  "agent:webhook-management",   // Can create/update webhooks
  "agent:autonomous-deploy"     // Can trigger deployments without approval
]

The agent: prefix scopes are purpose-built for autonomous workflows. They signal that this token is being used by an agent, not a human, which lets your backend apply different rate limits, audit trails, and approval flows.

Scope enforcement middleware

function requireScope(...requiredScopes) {
  return (req, res, next) => {
    const tokenScopes = req.auth.scope?.split(' ') || [];

    const hasRequired = requiredScopes.every(s => tokenScopes.includes(s));
    if (!hasRequired) {
      return res.status(403).json({
        error: 'insufficient_scope',
        error_description: `Required: ${requiredScopes.join(', ')}`,
        required_scopes: requiredScopes,
        granted_scopes: tokenScopes
      });
    }

    next();
  };
}

// Usage
app.get('/api/projects',
  authenticate,
  requireScope('read:projects'),
  projectController.list
);

app.post('/api/deployments',
  authenticate,
  requireScope('execute:deployments'),
  deploymentController.trigger
);

The insufficient_scope error response includes both the required and granted scopes. This is critical for agents — instead of a generic 403, they get a machine-readable explanation of what went wrong and what they need. A well-built agent can use this to request the correct scopes on its next attempt.

Step 3: Build the self-service client registration endpoint

This is where most SaaS companies stop short. They have OAuth. They have scopes. They even have a Client Credentials flow. But to get a client_id and client_secret, an agent has to... log into a dashboard and click "Create Application."

That's the equivalent of requiring a phone call to get an API key. It works, but it doesn't scale.

Here's a client registration endpoint that agents can use directly:

app.post('/api/v1/agents/register', async (req, res) => {
  const { agent_name, agent_type, requested_scopes, tos_accepted, billing_token } = req.body;

  // 1. Validate terms acceptance
  if (!tos_accepted) {
    return res.status(400).json({
      error: 'tos_required',
      error_description: 'Agent must accept Terms of Service',
      tos_url: 'https://yourcompany.com/terms/agent-tos',
      tos_version: '2026-02-01'
    });
  }

  // 2. Validate billing (prepaid token, credit card token, or usage-based)
  const billingValid = await validateBillingToken(billing_token);
  if (!billingValid) {
    return res.status(402).json({
      error: 'billing_required',
      error_description: 'Valid billing method required for agent registration',
      billing_options: [
        { type: 'prepaid', endpoint: '/api/v1/billing/prepaid' },
        { type: 'stripe', endpoint: '/api/v1/billing/card' }
      ]
    });
  }

  // 3. Validate and filter scopes
  const allowedScopes = filterScopes(requested_scopes, billingValid.tier);

  // 4. Generate credentials
  const clientId = `agent_${crypto.randomBytes(12).toString('hex')}`;
  const clientSecret = `sk_live_${crypto.randomBytes(32).toString('hex')}`;

  // 5. Store (hash the secret!)
  await db.clients.create({
    clientId,
    secretHash: hashSecret(clientSecret),
    agentName: agent_name,
    agentType: agent_type || 'autonomous',
    allowedScopes,
    billingId: billingValid.id,
    createdAt: new Date(),
    status: 'active'
  });

  // 6. Return credentials (only time the secret is shown)
  res.status(201).json({
    client_id: clientId,
    client_secret: clientSecret,  // ⚠️ Only returned once
    allowed_scopes: allowedScopes,
    token_endpoint: 'https://api.yourcompany.com/oauth/token',
    documentation: 'https://docs.yourcompany.com/agent-auth',
    rate_limits: {
      requests_per_minute: 60,
      requests_per_day: 10000
    },
    warning: 'Store the client_secret securely. It cannot be retrieved again.'
  });
});

This is the missing piece. The response gives the agent everything it needs: credentials, the token endpoint URL, documentation link, and rate limit info. No dashboard. No browser. No human.

What about abuse?

The obvious concern: "Won't agents spam our registration endpoint?"

Yes, if you don't gate it. But the gates don't need to be CAPTCHAs:

  • Billing requirement — requiring a valid payment method (even for free tiers) eliminates most abuse. The cost of a stolen credit card is higher than the cost of creating throwaway accounts.
  • Rate limiting on registration — 5 registrations per IP per hour, or per billing account per day.
  • Progressive trust — start agents on a restricted scope set. Expand access after they demonstrate legitimate usage.
  • Agent identity verification — require a signed attestation from a known agent framework (MCP, LangChain, etc.) that includes the agent's identity chain.

None of these require a human in the loop.

Step 4: Token verification in your API

Once agents have tokens, your API needs to verify them on every request. Here's a production-grade middleware:

const { jwtVerify, createRemoteJWKSet } = require('jose');

// Cache the JWKS for performance
const JWKS = createRemoteJWKSet(
  new URL('https://api.yourcompany.com/.well-known/jwks.json')
);

async function authenticate(req, res, next) {
  const authHeader = req.headers.authorization;
  if (!authHeader?.startsWith('Bearer ')) {
    return res.status(401).json({
      error: 'missing_token',
      error_description: 'Authorization: Bearer <token> header required',
      token_endpoint: 'https://api.yourcompany.com/oauth/token'
    });
  }

  const token = authHeader.slice(7);

  try {
    const { payload } = await jwtVerify(token, JWKS, {
      issuer: 'https://api.yourcompany.com',
      audience: 'https://api.yourcompany.com',
      clockTolerance: 30,  // 30s clock skew tolerance
    });

    // Attach verified claims to request
    req.auth = {
      clientId: payload.sub,
      scope: payload.scope,
      agentType: payload.agent_type,
      owner: payload.owner,
      tokenId: payload.jti,
    };

    // Optional: check token revocation list
    if (await isRevoked(payload.jti)) {
      return res.status(401).json({
        error: 'token_revoked',
        error_description: 'This token has been revoked'
      });
    }

    next();
  } catch (err) {
    if (err.code === 'ERR_JWT_EXPIRED') {
      return res.status(401).json({
        error: 'token_expired',
        error_description: 'Token has expired. Request a new one.',
        token_endpoint: 'https://api.yourcompany.com/oauth/token'
      });
    }

    return res.status(401).json({
      error: 'invalid_token',
      error_description: 'Token verification failed'
    });
  }
}

Note how every error response includes actionable information. When an agent gets token_expired, it knows to hit the token endpoint for a new one. When it gets missing_token, it knows the exact header format expected. This is the difference between an API that tolerates agents and one that's built for them.

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 →

Step 5: Agent-aware rate limiting

Agents and humans have fundamentally different traffic patterns. A human developer might make 10 API calls while testing in Postman. An agent running a workflow might make 500 calls in a minute — all legitimate, all serving a paying customer.

Standard rate limits designed for human usage patterns will kill agent adoption:

const rateConfigs = {
  human: {
    windowMs: 60_000,
    maxRequests: 60,      // 1 req/sec average
    burstMax: 10,         // Allow short bursts
  },
  agent_standard: {
    windowMs: 60_000,
    maxRequests: 300,     // 5 req/sec average
    burstMax: 50,
    dailyMax: 50_000,
  },
  agent_premium: {
    windowMs: 60_000,
    maxRequests: 1000,    // ~16 req/sec average
    burstMax: 200,
    dailyMax: 500_000,
  }
};

function agentAwareRateLimit(req, res, next) {
  const tier = req.auth?.agentType === 'autonomous'
    ? (req.auth.tier || 'agent_standard')
    : 'human';

  const config = rateConfigs[tier];
  const key = `rate:${req.auth?.clientId || req.ip}:${tier}`;

  // ... sliding window counter logic ...

  // Include rate limit info in every response
  res.set({
    'X-RateLimit-Limit': config.maxRequests,
    'X-RateLimit-Remaining': remaining,
    'X-RateLimit-Reset': resetTimestamp,
    'X-RateLimit-Tier': tier,
  });

  if (remaining <= 0) {
    return res.status(429).json({
      error: 'rate_limit_exceeded',
      error_description: `Rate limit of ${config.maxRequests}/min exceeded`,
      retry_after: retryAfterSeconds,
      upgrade_url: 'https://yourcompany.com/pricing#agent-tiers'
    });
  }

  next();
}

The 429 response includes retry_after (so the agent knows exactly when to retry) and an upgrade_url (so the agent — or its owner — knows how to get higher limits). This turns a rate-limiting event from a dead end into a conversion opportunity.

Step 6: Discovery — help agents find your auth

An agent can't use your OAuth endpoint if it doesn't know it exists. The OpenID Connect Discovery spec (RFC 8414) defines a standard well-known endpoint for this:

app.get('/.well-known/openid-configuration', (req, res) => {
  res.json({
    issuer: 'https://api.yourcompany.com',
    token_endpoint: 'https://api.yourcompany.com/oauth/token',
    registration_endpoint: 'https://api.yourcompany.com/api/v1/agents/register',
    jwks_uri: 'https://api.yourcompany.com/.well-known/jwks.json',
    scopes_supported: [
      'read:projects', 'write:projects',
      'read:analytics', 'write:webhooks',
      'agent:batch-operations'
    ],
    grant_types_supported: ['client_credentials'],
    token_endpoint_auth_methods_supported: [
      'client_secret_basic',
      'client_secret_post'
    ],
    response_types_supported: ['token'],

    // Agent-specific extensions
    agent_registration_url: 'https://api.yourcompany.com/api/v1/agents/register',
    agent_documentation: 'https://docs.yourcompany.com/agent-quickstart',
    agent_tos_url: 'https://yourcompany.com/terms/agent-tos',
    rate_limit_tiers: {
      standard: { requests_per_minute: 300 },
      premium: { requests_per_minute: 1000 }
    }
  });
});

Also add this to your llms.txt:

# Auth

## Agent Authentication
Machine-to-machine authentication uses OAuth 2.0 Client Credentials.

- Discovery: GET /.well-known/openid-configuration
- Registration: POST /api/v1/agents/register
- Token: POST /oauth/token (grant_type=client_credentials)
- Docs: https://docs.yourcompany.com/agent-auth

## Scopes
- read:projects — List and read project data
- write:projects — Create and modify projects
- read:analytics — Access analytics and reporting data

Between the well-known endpoint and the llms.txt entry, an agent can discover your auth system, register for credentials, and start making authenticated API calls — all without reading a single HTML documentation page.

The MCP connection

If you're building for AI agents in 2026, you should know about the Model Context Protocol (MCP). As of the March 2025 spec revision, OAuth 2.1 is mandatory for MCP server authentication. This isn't optional — MCP clients expect to discover an OAuth endpoint and authenticate via standard flows.

The Client Credentials implementation above is MCP-compatible. If you expose a /.well-known/openid-configuration and support the Client Credentials grant, MCP-based agents (Claude, GPT, and others building on the standard) can authenticate with your service natively.

This is the direction the ecosystem is moving. Building OAuth for agents today isn't speculative — it's building to a spec that's already being deployed.

Putting it all together: the agent's experience

Here's what the full agent authentication flow looks like from the agent's perspective:

import requests

BASE_URL = "https://api.yourcompany.com"

# 1. Discover auth configuration
config = requests.get(f"{BASE_URL}/.well-known/openid-configuration").json()
token_endpoint = config["token_endpoint"]
registration_endpoint = config["agent_registration_url"]

# 2. Register (first time only)
reg = requests.post(registration_endpoint, json={
    "agent_name": "data-analyst-agent",
    "agent_type": "autonomous",
    "requested_scopes": ["read:projects", "read:analytics"],
    "tos_accepted": True,
    "billing_token": "btok_prepaid_xxxxx"
}).json()

client_id = reg["client_id"]
client_secret = reg["client_secret"]
# Store these securely!

# 3. Get an access token
token_resp = requests.post(token_endpoint, data={
    "grant_type": "client_credentials",
    "client_id": client_id,
    "client_secret": client_secret,
    "scope": "read:projects read:analytics"
}).json()

access_token = token_resp["access_token"]

# 4. Use the API
headers = {"Authorization": f"Bearer {access_token}"}
projects = requests.get(f"{BASE_URL}/api/projects", headers=headers).json()
analytics = requests.get(f"{BASE_URL}/api/analytics", headers=headers).json()

Four HTTP requests. No browser. No CAPTCHA. No human. That's what agent-ready authentication looks like.

What to ship this week

If you're starting from zero, here's a phased approach:

Week 1: Client Credentials flow. If you already have OAuth, enable the Client Credentials grant. If you don't, stand up a token endpoint. This alone is the highest-impact change.

Week 2: Agent-specific scopes. Audit your existing scopes. Add agent: prefixed scopes for workflows that agents will use differently than humans.

Week 3: Self-service registration. Build the /agents/register endpoint. Gate it with billing, not CAPTCHAs.

Week 4: Discovery. Add /.well-known/openid-configuration and update your llms.txt. Make your auth findable.

Each step independently improves your agent readiness. You don't need all four to start seeing value. But companies that complete all four will be the ones that capture the fastest-growing segment of API consumers.

Run the AgentGate benchmark on your domain to see where you stand today, and how much these changes move your score.


Check your agent readiness score at anon-dev.com/benchmark. See how your company compares on the leaderboard.

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 →