Sign in AI agents

EndPointBlank is an OAuth 2.1 authorization server for AI agents such as MCP clients. A person signs in with their own EndPointBlank account and approves the agent to act as one of their organization's applications. The agent gets a short-lived access token for one provider's API, and no client secret is ever handed to it.

This page has two readers: agent developers building a client that signs in, and providers whose API should accept those agents. Both use this issuer:

https://app.endpointblank.com

For agent developers

Discovery

Find the authorization server

Call the provider's API without a token. An MCP server answers 401 and names its protected resource metadata (RFC 9728), whose authorization_servers lists EndPointBlank's issuer. Then read the authorization server metadata (RFC 8414) at /.well-known/oauth-authorization-server on the issuer. It needs no credentials and allows any origin (CORS), as do registration and the token endpoint.

authorization_endpoint url https://app.endpointblank.com/agent-oauth/authorize
token_endpoint url https://app.endpointblank.com/agent-oauth/token
registration_endpoint url https://app.endpointblank.com/agent-oauth/register
code_challenge_methods_supported list S256 only.
token_endpoint_auth_methods_supported list none only: every client is public.
authorization_response_iss_parameter_supported boolean true: every redirect back to the agent carries iss (RFC 9207).
service_documentation url This page.

There is no revocation endpoint: a person revokes an agent in EndPointBlank.

Register

Dynamic client registration

POST a JSON object to /agent-oauth/register (RFC 7591) with Content-Type: application/json. No sign-in is needed. The answer is 201 with your client_id. There is no client secret: token_endpoint_auth_method is always none, whatever you ask for. Register once per installation and keep the client_id.

redirect_uris required list One to five. Each https, or http on 127.0.0.1, [::1] or localhost, with no fragment or user info, at most 2,000 bytes.
client_name string Up to 200 characters, no control or format characters. Shown on the consent page as self-reported and unverified.
grant_types list If given, only authorization_code and refresh_token.
response_types list If given, only code.

Redirect URIs are compared byte for byte: no case folding, no trailing slash, no default port. The one exception is a loopback URI (127.0.0.1, [::1] or localhost), which also matches the same URI on any port, so a native agent can listen on whatever port it gets. Only the port may differ: localhost does not match 127.0.0.1, and the path and query must be identical.

Authorize

Send the person to sign in and approve

Open /agent-oauth/authorize in the person's browser with these query parameters. PKCE with S256 is required, and so is resource. scope is ignored.

response_type required string code
client_id required string From registration.
redirect_uri required string Always, even when only one is registered. One you registered, a loopback one on any port.
code_challenge required string The S256 challenge: the unpadded base64url SHA-256 of your verifier, 43 characters.
code_challenge_method required string S256
resource required url The provider API's resource (RFC 8707), from its protected resource metadata. Absolute http(s), with no query, fragment or user info.
state string Up to 1,000 bytes, sent back unchanged. Use a new one for each sign-in and check it.

The verifier is 43 to 128 unreserved characters (RFC 7636). With openssl:

CODE_VERIFIER=$(openssl rand -base64 48 | tr -d '\n=' | tr '+/' '-_')
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
  | openssl dgst -sha256 -binary | openssl base64 -A | tr -d '=' | tr '+/' '-_')

On approval the browser comes back to your redirect URI with code, your state and iss. Check that state is the one you sent and iss is the issuer above before you use the code. The code works once, for sixty seconds.

An unknown client_id, or a redirect_uri the client did not register, is shown to the person as an error page and never redirected. So are three refusals after sign-in: an organization with no access to the provider (403), an organization without a slug (409) and EndPointBlank staff impersonating an organization (403). The person sees a page and your agent hears nothing. Every other error goes to your redirect URI as error and error_description, with iss, and with your state unless it was over 1,000 bytes or not a string, in which case state is left out.

Consent

Who may approve

A signed-out person signs in to EndPointBlank first, or gets a one-time code by email for an account a provider runs for them, and comes back to the same request. The consent page shows the agent's self-reported name, marked as unverified, the host the code will be sent to, the provider's organization and application, and the organization the person is acting for, with links to switch.

  • Any member of the organization may approve, as one of the applications they can see (the same rule as the Applications pages). A member in no group sees none.
  • The organization must already have access to the provider: an internal grant within one organization, or across organizations an accepted client invite with access to the provider's application. Without it the page says so, nothing can be approved, and nothing is sent to the agent.
  • One exception: when the provider has invited the person's email address to be its client (the account invite POST /api/v1/clients sends to an existing EndPointBlank user) with access to this API, the page shows that request instead. An organization admin approves by accepting it, choosing an existing application or creating one to act as, in one step: the invite is accepted, its packages and grants applied, the application linked to the provider and the agent approved. A member who is not an admin is asked to get one.
  • An organization a provider manages for a customer has no members. Its owner signs in instead with a one-time code emailed to the address the provider set (owner_email on POST /api/v1/clients or PATCH /api/v1/clients/:id), without claiming it, and may approve any of its applications with access. A PATCH that changes the address revokes every agent the old address approved. Once the customer claims the organization, its admins see the owner's agents on the connected-agents page and may revoke them.
  • EndPointBlank staff impersonating an organization cannot approve, and an organization without a slug cannot connect agents.
  • Deny sends error=access_denied to your redirect URI.

Token

Exchange the code

POST to /agent-oauth/token, form-encoded or as a JSON object, in the body and never the query string. Send no client secret and no Authorization header.

grant_type required string authorization_code
code required string From the redirect.
client_id required string From registration.
redirect_uri required string Exactly as the authorize request sent it, port included, not as registered.
code_verifier required string The verifier the challenge was made from.
resource required url The same resource as the authorize request.

The answer is access_token, token_type Bearer, expires_in in seconds and refresh_token, with no scope. Every answer carries Cache-Control: no-store. A code refused with invalid_grant, or sent with a valid resource other than the one it was issued for, is spent all the same: start the sign-in again. A request refused before the code is looked at (invalid_request, invalid_client, or a resource that is not a base URL) leaves the code as it was. Send the access token to the provider's API as Authorization: Bearer.

Refresh

Rotate the refresh token, one request at a time

POST to /agent-oauth/token with grant_type=refresh_token, refresh_token and client_id. resource is optional; when sent it must be the one the code was issued for. Each refresh answers a new access token and a new refresh token, and spends the one you sent.

A refresh token works once. Presenting a spent one again revokes every refresh token of that sign-in, the one just issued from it included, however soon after and from whichever client. The agent can no longer refresh, and the person has to approve it again. The sign-in itself is not revoked: the access tokens already issued live out their hour. So:

  • Never send two refreshes of one token at once. Serialize them across every process that shares the token, and store the new refresh token before you use the new access token.
  • Do not resend a refresh whose answer you lost: to EndPointBlank it looks the same as a stolen token used twice.
  • After temporarily_unavailable (503), sending the same code or refresh token again is safe: nothing was spent.

An authorization code presented a second time revokes that sign-in's refresh tokens too.

Lifetimes

How long each credential lasts

Refresh before the access token expires. When the refresh token expires or is revoked, the person signs in again.

authorization code 60 seconds Single use.
access token 1 hour at most expires_in says how long this one has left. It stops working early if the sign-in is revoked.
refresh token 30 days Single use. Each refresh issues the next one, good for 30 days from then, so an agent that keeps refreshing keeps working.

Errors

Error codes

Errors are OAuth's JSON, with error and error_description, except the authorize step's, which arrive as query parameters on your redirect URI. Match on error, not on the description.

Registration (POST /agent-oauth/register)

invalid_redirect_uri 400 A redirect URI is not https, or http on 127.0.0.1, [::1] or localhost, or has a fragment or user info, or is over 2,000 bytes; or there are none or more than five.
invalid_client_metadata 400 The body is not a JSON object sent as application/json, client_name is too long or holds control characters, or grant_types or response_types is empty, not a list, or names one this server does not support.
slow_down 429 More than 20 registrations a minute from one address. Retry-After says when to try again.

Authorize (GET /agent-oauth/authorize), sent to the redirect URI

invalid_request redirect response_type is missing, PKCE is missing or not S256, code_challenge is not an S256 challenge, state is over 1,000 bytes, or resource is missing.
unsupported_response_type redirect response_type is not code.
invalid_target redirect resource is over 2,000 bytes or not an absolute http(s) URL without a query, fragment or user info, names no provider on EndPointBlank, or matches two providers equally (one base URL in two organizations).
access_denied redirect The user chose Deny.

Token (POST /agent-oauth/token)

invalid_request 400 A required parameter is missing or not a string, or the body is not form-encoded or a JSON object.
invalid_client 401 No agent registered this client_id. Checked before the code or refresh token is looked at.
invalid_grant 400 The code or refresh token is unknown, expired, spent or revoked, or was issued to another client_id or redirect_uri, or code_verifier does not match, or the authorization was revoked, or the provider's base URL no longer covers resource. One code for all of these.
unsupported_grant_type 400 grant_type is neither authorization_code nor refresh_token.
invalid_target 400 resource is not a base URL, or is not the one the code was issued for.
temporarily_unavailable 503 The access token could not be issued right now. Nothing was spent: send the same code or refresh token again.
slow_down 429 More than 60 token requests a minute for one client_id from one address. Retry-After says when to try again.

Revoking an agent

There is no token revocation endpoint for agents. A member of the organization revokes an agent under Settings, Connected Agents. An organization admin may revoke any of the organization's agents, and any other member the ones they approved. The owner of a managed account revokes from the page they reach after signing in with a code, or from a link in the provider's app (POST /api/v1/clients/:client_id/portal_sessions, a one-time URL that works for 60 seconds and signs the owner in the same way). Revoking revokes every refresh token at once, so the agent's next refresh is invalid_grant and it has to send the person through sign-in again. The access tokens already issued stop working once the revoke reaches intake, which happens in the background and is retried until it does.

For providers

Your API is an application in EndPointBlank with an environment whose base URL covers it, and your clients have access to it through API packages or grants. Agent sign-in lets a client's people connect an AI agent to your API as one of the client's own applications, under that same access. You never create a token for a client.

Step 1

Name EndPointBlank as your authorization server

Serve protected resource metadata (RFC 9728) at your API's /.well-known/oauth-protected-resource, with the well-known segment before the resource's path, and list EndPointBlank's issuer in authorization_servers. An MCP server also names that document in the WWW-Authenticate header of its 401.

GET https://api.provider.example/.well-known/oauth-protected-resource/mcp

{
  "resource": "https://api.provider.example/mcp",
  "authorization_servers": ["https://app.endpointblank.com"]
}

Step 2

Keep resource under your base URL

The resource must equal your application environment's base URL in EndPointBlank or sit under it on a path boundary: https://api.provider.example/mcp is under https://api.provider.example, but /orders-archive is not under /orders. Scheme and host are compared in lower case, a trailing slash is dropped and the path is compared exactly. A resource under no base URL is invalid_target. The access token's audience is your application environment's base URL, so a token issued for your API is refused for anyone else's.

Step 3

Verify the bearer token at intake

The agent sends its access token to your API as Authorization: Bearer. Verify it as you verify any EndPointBlank access token: pass it to intake's POST /api/authorize as the client_auth, as your SDK does. intake checks that it has not expired, that its audience is your base URL and that the sign-in has not been revoked. Answer 401 when intake refuses it.

Never

Do not use the agent's token as your own credential

intake never accepts an agent's access token as the Authorization header of a request to intake. Your API keeps authenticating to intake with its own credentials, and the agent's token only ever travels as the client_auth.

Example: the whole exchange with curl

Values in angle brackets are placeholders, and api.provider.example stands for the provider's API. The browser step needs a person; everything else runs in a shell.

ISSUER=https://app.endpointblank.com
RESOURCE=https://api.provider.example/mcp
REDIRECT_URI=http://127.0.0.1:8976/callback

# 1. Discovery: the provider names its authorization server, then read ours.
curl -s https://api.provider.example/.well-known/oauth-protected-resource/mcp
curl -s "$ISSUER/.well-known/oauth-authorization-server"

# 2. Register once. There is no client secret.
curl -s "$ISSUER/agent-oauth/register" \
  -H 'content-type: application/json' \
  -d '{"client_name": "Example agent", "redirect_uris": ["http://127.0.0.1:8976/callback"]}'
# 201 {"client_id": "<CLIENT_ID>", "token_endpoint_auth_method": "none", ...}
CLIENT_ID='<CLIENT_ID>'

# 3. A fresh PKCE verifier, its S256 challenge and a state for this sign-in.
CODE_VERIFIER=$(openssl rand -base64 48 | tr -d '\n=' | tr '+/' '-_')
CODE_CHALLENGE=$(printf '%s' "$CODE_VERIFIER" \
  | openssl dgst -sha256 -binary | openssl base64 -A | tr -d '=' | tr '+/' '-_')
STATE=$(openssl rand -hex 16)

# 4. Open this in the user's browser. They sign in and approve.
echo "$ISSUER/agent-oauth/authorize?response_type=code&client_id=$CLIENT_ID\
&redirect_uri=http%3A%2F%2F127.0.0.1%3A8976%2Fcallback\
&code_challenge=$CODE_CHALLENGE&code_challenge_method=S256\
&resource=https%3A%2F%2Fapi.provider.example%2Fmcp&state=$STATE"
# The browser comes back to
#   http://127.0.0.1:8976/callback?code=<CODE>&state=<STATE>&iss=<ISSUER, URL-encoded>
# Use the code only if state is $STATE and iss is $ISSUER.
CODE='<CODE>'

# 5. Exchange the code within 60 seconds.
curl -s "$ISSUER/agent-oauth/token" \
  -d grant_type=authorization_code \
  -d client_id="$CLIENT_ID" \
  -d code="$CODE" \
  --data-urlencode redirect_uri="$REDIRECT_URI" \
  -d code_verifier="$CODE_VERIFIER" \
  --data-urlencode resource="$RESOURCE"
# {"access_token": "<ACCESS_TOKEN>", "token_type": "Bearer",
#  "expires_in": 3599, "refresh_token": "<REFRESH_TOKEN>"}

# 6. Call the provider's API with the access token.
curl -s "$RESOURCE" -H 'Authorization: Bearer <ACCESS_TOKEN>'

# 7. Before the hour is up, refresh. Keep the new refresh token: the old one is spent.
curl -s "$ISSUER/agent-oauth/token" \
  -d grant_type=refresh_token \
  -d client_id="$CLIENT_ID" \
  -d refresh_token='<REFRESH_TOKEN>'
# {"access_token": "<NEW_ACCESS_TOKEN>", "token_type": "Bearer",
#  "expires_in": 3599, "refresh_token": "<NEW_REFRESH_TOKEN>"}