The key idea
Poppy reuses standard OAuth. Your server has to trust agents by their client_id URL, start signed-out sessions from an agent's signed assertion, carry a session_id through sign-in, bind every token to the agent's DPoP key, and revoke on sign-out.
Most of the Personal Agent Protocol’s sign-in work happens in your OAuth authorization server. The good news: almost all of it is standard OAuth. The work is turning on the right pieces and adding a session ID that ties them together.
The pieces at a glance
| Piece | Standard | Needed for |
|---|---|---|
Metadata with poppy_domains |
RFC 8414 | Every company with auth |
| Agents identified by a metadata URL | OAuth Client ID Metadata Document | Every company with auth |
Client authentication with private_key_jwt |
RFC 7523 | Every token request |
Signed-out sessions via the JWT bearer grant, plus session_id |
RFC 7523 | Every company with auth |
| DPoP-bound tokens | RFC 9449 | APIs and conversations |
Authorization code with PKCE (S256), iss in redirects |
RFC 6749, 7636, 9207 | Direct sign-in |
| Device authorization grant | RFC 8628 | Device sign-in |
| Refresh tokens as Account Tokens | RFC 6749 | Signing in once, using it later |
| Token revocation | RFC 7009 | Sign-out |
| Protected resource metadata | RFC 9728 | MCP servers |
Metadata and poppy_domains
Publish RFC 8414 metadata at your issuer (for issuer https://auth.example.com, that is https://auth.example.com/.well-known/oauth-authorization-server). It must contain:
issuer, exactly the same string asauth.issuerin your poppy.json;token_endpointandrevocation_endpoint;authorization_endpointif you offer direct sign-in, anddevice_authorization_endpointif you offer device sign-in;poppy_domains, listing every domain whose poppy.json names this issuer.
It is worth also advertising what agents will need: private_key_jwt in token_endpoint_auth_methods_supported, the JWT bearer grant in grant_types_supported, S256 in code_challenge_methods_supported, your DPoP algorithms in dpop_signing_alg_values_supported, and authorization_response_iss_parameter_supported: true.
Trusting agents by URL
A personal agent’s client_id is an https URL that returns its metadata: name, logo, jwks_uri, redirect_uris and token_endpoint_auth_method: private_key_jwt. There is no registration step by default. Your server:
- fetches the
client_idURL (no redirects) and checks the document’sclient_idequals that URL; - checks
jwks_uriand every redirect URI are https on the same domain as theclient_id; - verifies the agent’s client assertion with a key from its
jwks_uri.
Fetch these safely: short timeouts, a size limit, and no private or loopback addresses. Cache them within bounds, refetch the key set when you see an unknown kid, and keep allowlists, blocklists and rate limits by client_id. You may require registration instead; an unregistered agent then gets invalid_client.
Starting a session
A session starts signed out. The agent sends a JWT bearer grant whose assertion names itself in iss and the user’s opaque ID in sub, with your token endpoint as aud, a lifetime of about a minute and a random jti:
POST /oauth/token
Content-Type: application/x-www-form-urlencoded
DPoP: <proof>
grant_type=urn:ietf:params:oauth:grant-type:jwt-bearer
&assertion=<agent's signed assertion>
&client_id=https://agent.example/agent.json
&client_assertion_type=urn:ietf:params:oauth:client-assertion-type:jwt-bearer
&client_assertion=<signed client assertion>
Verify both assertions, check the assertion’s iss is the authenticated client, reject any jti you have seen, then answer with a new session:
{ "access_token": "…", "token_type": "DPoP", "expires_in": 3600, "scope": "", "session_id": "ses_2Lm0", "signed_in": false }
The agent renews a signed-out token by sending the same grant with session_id. Keep a record per session: the client_id, the user ID and, once signed in, the account. A session can be signed in to one account only; a request for another gets account_mismatch.
DPoP on every request
Session Tokens are bound to a key the agent holds. Every API and conversation request carries Authorization: DPoP <token> plus a DPoP proof, a small JWT signed by that key. On each request, check:
- the proof’s signature, with the public key in its header, and that this is the key the token is bound to;
htmandhtumatch the request (buildhtufrom your public origin, not theHostheader behind a proxy);iatis within about a minute, andjtihasn’t been used in that window (one replay cache shared by every server);athis the hash of the token sent.
You may require server nonces. The one exception is MCP: MCP authorization uses Bearer tokens, so an agent requests a token without a proof, with resource set to your MCP server’s URL, and that Bearer token must work only at that MCP server. Accept Bearer session tokens nowhere else.
Sign-in
Offer at least one of these, and list it under auth in poppy.json:
- Direct: the authorization code flow with PKCE (S256 only). Your consent page shows the agent’s name, logo and
client_iddomain, the account, and each scope, and lets the user approve some or all of them. Returnissin every redirect. When the agent exchanges the code it addssession_id, and you sign in that session. - Device: RFC 8628. Opening the link must never approve by itself; the user signs in and approves on the page.
- Mediated: the agent posts the user’s credentials (the fields you list) to your endpoint with its session token. You answer
complete,code_required(with a one-time code sent to the user),failedorexpired. This puts more on you: rate-limit attempts, never return credentials, ask for a code when a sign-in looks unusual, and tell the user when an agent signs in.
All three end the same way: a refresh token (the Account Token) and a signed-in Session Token for the current session. Grant no more than was requested and allowed for that sign-in type, and return the scopes actually granted.
Account Tokens and sign-out
An Account Token lets the agent start signed-in sessions later. Accept it only at your token and revocation endpoints, only from the client_id it was issued to, authenticated by its client assertion. Agents may ask for fewer scopes than it holds, never more.
Sign-out is revocation (RFC 7009): revoke the Account Token and sign out every session that used it. Check sign-in state on each request so it takes effect at once, or keep session tokens to a few minutes. Give users an account settings page listing each connected agent, its scopes and last use, with a Disconnect button.
Scopes only matter if they are enforced everywhere: on your website, your APIs, your company agent’s tools and your support staff’s tools. poppy:write does not include poppy:read.
If your provider can’t
Many hosted OAuth providers can do PKCE, device flow, refresh tokens and revocation today, but not all of them support client ID metadata documents, the JWT bearer grant with a custom session_id, DPoP-bound tokens or extra metadata fields. Check your provider’s documentation for each row of the table above. Where one is missing:
- Metadata fields: serve the metadata document yourself at the well-known URL, copying your provider’s endpoints and adding
poppy_domains. Agents only read it. - Sessions and DPoP: put a small gateway in front of your token endpoint and your APIs. It verifies agent assertions and DPoP proofs, issues or wraps tokens bound to the proof’s key, tracks sessions, and checks the key on every later request. Checking a proof’s signature alone is not enough.
- Agents by URL: resolve the
client_iddocument in that gateway and map it to a client your provider knows, or require registration.
Then run the checker to see what agents can see, and work through the checklist for the rest.
Free Poppy checker
See what your domain needs for the Personal Agent Protocol, one step at a time.
Check your domainWritten against Personal Agent Protocol (Poppy, PAP) draft 0.1, which may change. Flow is not affiliated with the protocol's authors.