CustomDomain Docs

The OAuth flow

The agent authorization endpoints: client metadata documents or DCR, authorize with PKCE, consent, token, refresh, revoke, plus JWKS and RFC 8414 discovery.

Live on customdomain.ai. Every endpoint below is serving in production. Verified 2026-08-02: the metadata document returns 200, /.well-known/jwks.json publishes the ES256 signing key, and dynamic client registration issues a real client_id.

Agent access uses OAuth 2.0 authorization-code with mandatory PKCE (S256), against the control plane at https://api.customdomain.ai. All of it is discoverable: a compliant client can bootstrap from the metadata document alone.

Discovery

Two well-known documents describe the whole surface:

# RFC 8414 Authorization Server Metadata
curl https://api.customdomain.ai/.well-known/oauth-authorization-server
# → { "issuer", "authorization_endpoint", "registration_endpoint",
#     "token_endpoint", "revocation_endpoint", "jwks_uri",
#     "scopes_supported": ["domains:read","domains:connect","domains:disconnect","domains:purchase"],
#     "code_challenge_methods_supported": ["S256"],
#     "token_endpoint_auth_methods_supported": ["none"],
#     "client_id_metadata_document_supported": true, … }

# RFC 7517 JWKS: the ES256 public keys resource servers verify tokens against
curl https://api.customdomain.ai/.well-known/jwks.json

A client that starts at the MCP endpoint needs no other address: the 401 from https://mcp.customdomain.ai/mcp points at its protected resource metadata (RFC 9728), which names this server first as the authorization server.

1. Identify your agent

Agents are public clients (PKCE, no secret). There are two ways to get a client_id, and both lead to the same authorize, consent and token steps.

The MCP authorization spec prefers this. Publish a JSON document about your agent at an https URL with a path, and use that URL itself as your client_id. Nothing is registered ahead of time, and one document serves every workspace that connects your agent.

{
  "client_id": "https://atlas.example/oauth/client.json",
  "client_name": "Atlas",
  "redirect_uris": ["https://atlas.example/cb", "http://127.0.0.1/callback"],
  "grant_types": ["authorization_code", "refresh_token"],
  "response_types": ["code"],
  "token_endpoint_auth_method": "none"
}

CustomDomain™ fetches the document when your agent first sends someone to the authorize endpoint, and uses it only when:

  • client_id in the document is exactly the URL it was fetched from;
  • it names the agent (client_name) and lists at least one redirect URI (https, or http on localhost, 127.0.0.1 or [::1]);
  • it asks for public-client authentication (none, or no method at all) and carries no client secret;
  • if it lists grant_types or response_types, they include authorization_code and code (other values are ignored);
  • it is served as application/json, is at most 5 KB, and answers 200 directly from a public host (redirects are not followed).

Documents are cached as their HTTP caching headers allow (5 minutes when they give none, never more than 24 hours), so a change reaches CustomDomain™ within that time.

Option B: Dynamic Client Registration

Register with RFC 7591. The standard metadata MCP clients send (grant_types, response_types, token_endpoint_auth_method, and fields such as client_uri or application_type, which are ignored) is accepted. Redirect URIs must be https (or http on a loopback host for native apps); at most 10 per client.

curl -X POST https://api.customdomain.ai/oauth/agent/register \
  -H "Content-Type: application/json" \
  -d '{"client_name":"Atlas","redirect_uris":["https://atlas.example/cb"]}'
# → { "client_id":"agc_…", "client_id_issued_at":1790000000,
#     "token_endpoint_auth_method":"none",
#     "grant_types":["authorization_code","refresh_token"],
#     "response_types":["code"] }

Registration is open, so it is bounded: each address may register 20 clients per hour (ten times that for registrations whose redirect URIs are all a known assistant's own callback, since hosted assistants register for every user from a few addresses), and a client nobody approves into a workspace is removed after 30 days; register again if yours was. Grant and response types beyond authorization_code and code are ignored. Errors use the RFC 7591 shape, for example {"error":"invalid_redirect_uri","error_description":"…"}.

Names. Your agent's name is shown to the person approving it, so it may not claim a brand that its redirect addresses do not belong to. A client named after Claude, Anthropic, ChatGPT, OpenAI, Devin or CustomDomain™ is refused unless every redirect URI is on that brand's own domains or on a loopback address. Names are at most 100 characters, may not contain control or invisible characters, and may not mix Latin letters with look-alike letters from another alphabet (Cyrillic, Greek, Armenian, Cherokee, Lisu or Coptic).

2. Authorize

Send the human to the authorize endpoint with a PKCE challenge. The control plane validates the client, the exact redirect URI, and the requested scopes, then redirects the browser to your console's consent screen.

GET https://api.customdomain.ai/oauth/agent/authorize
  ?client_id=agc_…
  &redirect_uri=https://atlas.example/cb
  &response_type=code
  &code_challenge=<BASE64URL(SHA256(verifier))>
  &code_challenge_method=S256
  &scope=domains:read domains:connect
  &state=<opaque>

The redirect URI must exactly match one registered in step 1 (or listed in your metadata document), and code_challenge_method must be S256; plain PKCE is rejected. The optional resource parameter (RFC 8707) names the server the token is for: the MCP endpoint, which is the default, or the API origin. Any other value is refused with invalid_target. Send the same resource on the token request. A loopback redirect URI such as http://127.0.0.1/callback matches on any port, so a native app can bind a new port each session (RFC 8252).

The console authenticates the owner and shows what the agent is requesting, led by where its access would go: the host of your redirect URI. That host is labelled verified (a known assistant's own address, such as claude.ai or chatgpt.com), local tool (a loopback address, with a warning), or unverified; an unverified agent can only be approved after the owner confirms they recognize the host. The screen also names the workspace and the application the agent will act in, which the owner picks from the workspace's live applications (test mode applications are not offered). On approval the console hands the browser back to the agent's redirect_uri with an authorization code and the original state. If the owner denies, a verified or local agent gets error=access_denied and the state; an unverified one is not redirected to. (The consent record itself is written server-side with your workspace credential, POST /v1/agent/consent. The control plane checks the approval again: an unverified host needs acknowledged_unverified: true, the owner's confirmation, or the approval is refused with 400; a name that imitates a brand is refused with 403; and a member's own key records only that member as the approver. See Managing access.)

4. Token

Exchange the code and the PKCE verifier for tokens. client_id and redirect_uri must match the authorization request.

curl -X POST https://api.customdomain.ai/oauth/agent/token \
  -d "grant_type=authorization_code" \
  -d "code=cod_…" \
  -d "code_verifier=<VERIFIER>" \
  -d "client_id=agc_…" \
  -d "redirect_uri=https://atlas.example/cb"
# → { "access_token":"eyJ…", "token_type":"Bearer", "expires_in":900,
#     "refresh_token":"art_…", "scope":"domains:read domains:connect" }

The access token is an ES256 JWT valid for 15 minutes; it carries the tenant, the bound application, the granted scopes, its issuer (iss), its audience (aud, the resource it was issued for) and the consenting member. The refresh token is long-lived and stored hashed at rest.

The code is good for one exchange within 5 minutes. Presenting a code that already issued tokens is refused with invalid_grant and revokes the grant those tokens came from (RFC 6749 section 4.1.2): someone else may hold a copy of the code, and their tokens cannot be told apart from yours. The workspace is told, as for a reused refresh token. A replay is caught for 5 minutes after the exchange.

Refresh

curl -X POST https://api.customdomain.ai/oauth/agent/token \
  -d "grant_type=refresh_token" \
  -d "refresh_token=art_…" \
  -d "client_id=agc_…"
# → { "access_token":"eyJ…", "refresh_token":"art_…(a new one)", … }

client_id is required and must be the client the grant belongs to. A refresh without resource gets the default audience, the MCP endpoint. Refresh tokens rotate: every refresh returns a new refresh token and the one you sent stops working, so store the new one each time. Presenting a refresh token that was already used is treated as theft: the whole grant is revoked and the owner is told why, and the agent has to be approved again. The one exception is your own client sending the same token twice within a few seconds (two requests refreshing at once): the second is refused with invalid_grant and the grant stands, so keep the token from the response that succeeded. Refresh keeps working until the grant is revoked or expires. Revocation takes effect at once: the control plane checks the grant on every call, so the next tool call and the next refresh are refused.

5. Use the token

Call MCP tools with the access token. The MCP verifies it locally against the JWKS (no introspection), checks that its audience is the MCP endpoint, and enforces the tool's required scope:

curl -X POST https://mcp.customdomain.ai/mcp \
  -H "Authorization: Bearer eyJ…" \
  -H "Content-Type: application/json" \
  -d '{"jsonrpc":"2.0","id":1,"method":"tools/call",
       "params":{"name":"connect-domain","arguments":{"domain":"app.customer.com"}}}'

A token whose grant lacks the tool's scope is refused with insufficient_scope, and one issued for another audience with 401 invalid_token. The control plane verifies the token again on every call the tool makes: it accepts it only when its issuer (iss) is the control plane itself and its audience (aud) is the MCP endpoint or the API origin, and only while the grant is active. A token whose grant was revoked still lists tools, but its first tool call is answered HTTP 401 with a WWW-Authenticate challenge, so the client refreshes or signs in again.

6. Revoke

curl -X POST https://api.customdomain.ai/oauth/agent/revoke -d "token=art_…"

Per RFC 7009 this always returns 200. Revoking kills the grant and all its refresh tokens; the owner can also revoke from the console.

Security notes

  • PKCE S256 is mandatory and verified with a constant-time comparison.
  • Exact redirect-URI match: an unregistered URI is refused without redirecting (no open redirect).
  • Authorization codes are single-use and live 5 minutes; a replayed code is refused and revokes the grant it issued tokens for.
  • Refresh tokens rotate, and a reused one revokes the grant.
  • The consent screen shows where access goes (the redirect host) and whether it is verified, and refuses agents whose name imitates a brand their host does not belong to. The control plane holds the approval to the same rules: an unverified host needs the owner's acknowledgement (acknowledged_unverified), whoever calls it.
  • Tokens are checked at both ends: the MCP verifies the signature and the audience, and the control plane checks the issuer, the audience and the grant on every call.
  • Tokens are ES256 with an alg-confusion guard: an alg:none or HS256 forgery is never accepted.

On this page