Agent Auth Protocol: How AI Agents Get Permission to Act
by Adithya Hebbar, System Analyst
If you've connected an AI agent to anything real, you've probably hit this pattern:
- You generate an API key for the agent.
- You paste it into a config file or environment variable.
- The agent can now call every endpoint that key can reach, forever, until you remember to revoke it.
There's no step where anyone asks "should this specific agent be allowed to do this specific thing, right now?" The key either works or it doesn't. That's a reasonable model for a script you wrote yourself. It falls apart once the caller is something that decides, on its own, what to do next.
Why API keys and standard OAuth don't fit
An API key answers exactly one question: is this request coming from someone who holds a valid key? That's the entire model, and it leaves a few things unanswered:
- What is the caller actually trying to do? A key doesn't carry intent. It's a static credential, not a live decision.
- Should a human see this before it happens? There's no approval step. Nothing pauses for a yes or no.
- Can I allow the safe actions but gate the risky ones? Keys are all-or-nothing once issued. You can't say "check the weather freely, but ask me before sending a message."
- Can I revoke access to one agent without breaking three others? A shared key ties everyone's access to the same string.
Standard OAuth solves some of this for human users. It assumes a person sitting at a browser, consenting once, up front, to a fixed scope. That doesn't match an agent that shows up with no prior relationship to you, mid-task, asking for a narrow capability it didn't need five minutes ago. OAuth-proxy patterns can bridge some of that gap for HTTP APIs, but they still start from a scope model built for browsers, not for a caller deciding on its own what to do next.
Agent Auth Protocol (AAP) is built around that difference. Agents register themselves, request specific capabilities instead of blanket access, and a human reviews and constrains what gets granted, before the agent can act.
AAP is an open specification published by Better Auth. It isn't tied to Better Auth's own product; any server can implement it. The walkthrough below simplifies the wire format for clarity, real field names and endpoints are in the References section at the end of this post.
The rest of this post walks through how this plays out, using a real capability as an example: an agent asking to send a message on someone's behalf.
| API key | Standard OAuth | AAP | |
|---|---|---|---|
| Grants access per | Key, all endpoints it can reach | Human, at consent time | Capability, one at a time |
| Carries intent | No | No | Yes, typed input/output schema |
| Approval step | None | Once, up front | Per new capability, human-in-the-loop |
| Partial trust (some actions, not others) | No | No | Yes, via constraints |
| Revoke one caller without affecting others | No, if key is shared | Yes | Yes, per agent identity |

The four mechanisms behind AAP
1. Discovery. An agent shouldn't need to be told out-of-band what a provider supports. It hits GET /.well-known/agent-configuration, the same pattern OpenID Connect uses for /.well-known/openid-configuration, and gets back the capability catalog, the supported identity modes, and how to authenticate. No manual onboarding, no shared doc.
2. Capabilities instead of scopes. A flat scope string like messages:write tells you almost nothing about what actually happens when it's used. AAP capabilities are schema-typed: a name, an input schema, an output schema, and a flag for whether approval is required. An agent can read the schema and know exactly what request shape will be accepted, without a human writing separate integration docs.
3. Human-in-the-loop approval. A new agent, or an existing agent asking for a new capability, lands in a pending state until a human decides. This borrows directly from RFC 8628, the OAuth 2.0 Device Authorization Grant: the same flow you use to log into a smart TV app, show a code, approve it on a device you trust. AAP repurposes that shape for machine-to-machine trust instead of a TV remote.
4. Constraints, not just yes or no. Approval isn't binary. A human can grant a capability but cap its parameters, or lock one of its fields to a fixed value. The capability is real, but the blast radius is whatever the human actually signed off on, not the full range the schema allows. It's the same instinct behind constraining what an agent can do once a prompt injection slips through: assume the worst input will eventually arrive, and design the boundary so a bad instruction has nowhere to go.

Walking through one capability, end to end
Take a concrete case: an agent wants a send_message capability, the ability to send a message to someone on the user's behalf. Here's its schema:
JavaScript
{
name: 'send_message',
description: 'Send a message. Requires approval and a constraint locking the recipient.',
requiredConstraints: ['recipient'],
input: {
type: 'object',
required: ['recipient', 'body'],
properties: {
recipient: { type: 'string', description: 'Recipient email address' },
body: { type: 'string', maxLength: 1000 }
}
},
output: {
type: 'object',
properties: { sent: { type: 'boolean' }, to: { type: 'string' }, preview: { type: 'string' } }
}
}
Two things about this schema matter before the agent ever runs:
- It's flagged as needing approval, so it starts out unusable.
- It declares
requiredConstraints: ['recipient'], which means the protocol won't let anyone grant it without pinning it to a specific address. "Send to anyone" isn't an option that gets discouraged. It's an option that doesn't exist.
(The spec itself expresses constraints as operators on the grant object, in, not_in, max, min, rather than the flat shorthand above. Locking recipient to one address is constraints: { recipient: { in: ['[email protected]'] } }. The shorthand here is for readability; the enforcement logic is the same either way.)
The full sequence
Discovery and registration. The agent's host reads the provider's configuration, sees send_message in the catalog marked as approval-required, and registers the agent. The agent now has an identity, but send_message sits in a pending list. Calling it right now just fails.
The pending request reaches a human. Wherever the human is watching, a dashboard, a notification, a link, they see something specific: this agent wants send_message, and it has to be constrained to a recipient. They can't just click approve, because the constraint is required:
JavaScript
if (cap === 'send_message') {
const recipient = fields['recipient']?.trim();
if (!recipient) {
return error('Recipient required for send_message');
}
grant({ name: cap, constraints: { recipient } });
}
No recipient, no grant. Both the approval UI and the server refuse to produce a capability that's missing its required constraint.
The grant is recorded with its constraint attached. The provider now knows something specific: this agent may call send_message, but only where recipient matches the address the human entered. That check runs on every future call, not just once at grant time.
Execution is checked against the constraint before anything runs:
JavaScript
case 'send_message':
return { sent: true, to: args.recipient, preview: args.body.slice(0, 60) }
If the agent tries to send to a different address, the request is rejected before this code executes. The constraint lives at the protocol layer, not inside the capability's own logic. It can't be bypassed just because someone forgot to add a check.
Every step is an event. Registration, the pending request, the approval and its constraint, the execution and its result, each one is logged with a timestamp and an agent ID. "Who sent that message" always has a specific answer: this agent, granted by this approval, constrained to this one recipient, at this time. Not "a valid key was used."
Two ways an agent can be "someone"
AAP separates an agent acting as a specific human from an agent acting on its own, because the two need different identity handling. Every agent gets the same identity primitives, an agent_id and a keypair, but each one is created with a mode field set to "delegated" or "autonomous". That field is immutable once set. It changes who approves what, not the shape of the credential.

Delegated mode: the agent stands in for a person. The approval ties the agent to whoever approved it, the same way an OAuth access token is tied to the user who consented. Every action traces back to that person.
Autonomous mode: there's no human behind each individual action, so the protocol mints an identity for the agent itself instead of borrowing one:
JavaScript
resolveAutonomousUser: async ({ host }) => ({
id: host?.id ?? 'autonomous',
name: 'Autonomous Agent',
email: `agent-${(host?.id ?? 'sys').slice(0, 8)}@demo.local`,
});
Either way, "who did this" has an answer. agent_9f21ac sent a message, granted by [email protected], constrained to [email protected] is a log line you can act on. a valid API key sent a message is not.
Where this fits alongside MCP
If you've worked with the Model Context Protocol, this will look familiar: MCP's own authorization spec builds on OAuth 2.1 for the transport layer, letting an MCP client act on behalf of a resource owner. AAP sits at a similar layer but goes further into the specifics an agent-shaped caller actually needs: typed capabilities instead of generic scopes, and constraints that get checked on every call, not just at token-issue time. The two aren't competing so much as solving adjacent parts of the same problem, whether the caller is an MCP tool, an orchestrator, or an agent: an MCP server can use exactly this kind of protocol as its authorization backend.
Summary
- API keys can't express intent, approval, or partial trust. They're a single static credential with no room for "ask me first" or "only up to this limit."
- Discovery and typed capabilities make permissions self-describing. An agent (or a human reviewing a request) can read the schema and know exactly what's being asked for.
- Human approval doesn't have to mean human review of every call. It means the first ask for a new capability is a deliberate decision. Everything after that grant runs at agent speed, inside the boundary that was actually agreed to.
- Constraints turn "yes" into "yes, but." Required constraints, like locking
send_messageto one recipient, close off the failure mode where a broad grant gets used more broadly than anyone intended. - Every event needs an owner. Whether an agent is delegated or autonomous, the audit trail should always answer who did what, under whose approval, with what limits.