Connect with OAuth
Sign in with PilotFox from an MCP client that supports MCP authorization: discovery, client ids, organization choice, consent, scopes, token checks and revocation.
A client that supports MCP authorization can sign a person in to PilotFox instead of using an API key. The person picks an organization and approves the permissions the client asks for; the client then gets an access token that acts as that person, in that organization, never beyond their current role there.
PilotFox's authorization server is built into the instance: OAuth 2.1 authorization code with PKCE, public clients only, with URL client ids (Client ID Metadata Documents) and no dynamic client registration.
Where OAuth is available
On pilotfox.app, yes. On a self-hosted instance, only when MCP_ENABLED is on and PUBLIC_URL is https (or
http on a loopback host, for development); GET /api/v1/config answers "mcpOAuth": true then. Everywhere else,
connect with an API key.
Discovery
A client finds everything from the MCP endpoint itself. A request without a credential is answered 401 with a
challenge naming the protected-resource metadata (RFC 9728):
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer resource_metadata="https://pilotfox.app/.well-known/oauth-protected-resource/api/mcp"
Content-Type: application/json
{"jsonrpc":"2.0","error":{"code":-32001,"message":"missing authorization header"},"id":null}| Document | URL on pilotfox.app |
|---|---|
| Protected-resource metadata | https://pilotfox.app/.well-known/oauth-protected-resource/api/mcp (also at /.well-known/oauth-protected-resource) |
| Authorization-server metadata (RFC 8414) | https://pilotfox.app/.well-known/oauth-authorization-server/api/auth |
| OpenID Connect discovery | https://pilotfox.app/api/auth/.well-known/openid-configuration |
{
"resource": "https://pilotfox.app/api/mcp",
"authorization_servers": ["https://pilotfox.app/api/auth"],
"bearer_methods_supported": ["header"],
"dpop_signing_alg_values_supported": ["EdDSA", "ES256", "ES512", "PS256", "RS256"],
"scopes_supported": [
"site:read", "analytics:read", "sessions:read", "users:read", "goals:read", "goals:write",
"funnels:read", "funnels:write", "segments:read", "annotations:write", "revenue:read"
]
}The authorization-server metadata names the endpoints, all under https://pilotfox.app/api/auth:
| Field | Value |
|---|---|
issuer | https://pilotfox.app/api/auth |
authorization_endpoint | https://pilotfox.app/api/auth/oauth2/authorize |
token_endpoint | https://pilotfox.app/api/auth/oauth2/token |
jwks_uri | https://pilotfox.app/api/auth/jwks |
revocation_endpoint | https://pilotfox.app/api/auth/oauth2/revoke |
token_endpoint_auth_methods_supported | none |
revocation_endpoint_auth_methods_supported | none |
grant_types_supported | authorization_code, refresh_token |
code_challenge_methods_supported | S256 |
client_id_metadata_document_supported | true |
It has no registration_endpoint, and there is no client_credentials grant: every token belongs to a person. The
client authentication methods are none alone, as PilotFox accepts public clients only (see
Public clients only), so the metadata names no signing algorithms for client assertions. It has
no introspection_endpoint either: introspection needs client credentials, which PilotFox refuses. The OpenID Connect
discovery document states the same values.
Client ids
PilotFox has to know the client before it can sign anyone in. It accepts two kinds of client id.
A URL: Client ID Metadata Document
The client's id is an https URL where the client publishes a JSON document describing itself. This is the usual
choice for MCP clients, and needs nothing from you. When a client presents such an id, PilotFox fetches the document
and checks it:
| Rule | |
|---|---|
| The URL | https, with a path, no . or .. segments, no fragment, no user name or password, on port 443 (or 80), on a public address: an id pointing at a private, loopback or reserved address is refused ("client_id URL must not target a private or reserved address"). |
| The fetch | No redirects followed, at most 5 seconds, a JSON content type, at most 5 KiB. |
client_id | Exactly the document's own URL. |
client_name | Required, not empty: the consent page shows it. |
redirect_uris | Required, not empty. https URLs without a fragment; http only on a loopback host (localhost or a name ending in .localhost, 127.0.0.0/8, [::1]); or a private-use scheme in reverse-domain form, such as com.example.app:/callback. |
token_endpoint_auth_method | "none", or left out (it defaults to "none"). Any other method is refused (invalid_client): the secret methods by the document rules, and private_key_jwt, which those rules allow, by PilotFox ("Only public clients are supported: token_endpoint_auth_method must be "none""). |
| Forbidden fields | Server-side fields such as client_secret, client_secret_expires_at, skip_consent or require_pkce. |
client_uri, post_logout_redirect_uris | When present, on the same origin as the client_id URL. |
{
"client_id": "https://assistant.example.com/oauth/client-metadata.json",
"client_name": "Example Assistant",
"client_uri": "https://assistant.example.com",
"redirect_uris": ["http://127.0.0.1:33418/callback"],
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"token_endpoint_auth_method": "none"
}A client registered by an administrator
A system administrator of the instance can register a client and give its id to the client's users. On pilotfox.app
that is the PilotFox team; on your own instance, you. Registration is POST /api/auth/oauth2/create-client, open to
system administrators only, with a signed-in session. The client must be public
("token_endpoint_auth_method": "none"); a desktop or CLI client that receives the code on a loopback address needs
"application_type": "native":
curl https://pilotfox.app/api/auth/oauth2/create-client \
-H "Cookie: $ADMIN_SESSION_COOKIE" \
-H "Origin: https://pilotfox.app" \
-H "Content-Type: application/json" \
-d '{
"client_name": "Internal reporting agent",
"redirect_uris": ["http://127.0.0.1:8976/callback"],
"application_type": "native",
"token_endpoint_auth_method": "none",
"grant_types": ["authorization_code", "refresh_token"],
"response_types": ["code"],
"scope": "openid offline_access site:read analytics:read goals:read goals:write"
}'The answer (201) carries the generated client_id. token_endpoint_auth_method must be sent as "none": any other
value, or leaving it out, is refused (400 invalid_client_metadata). A client of the default application_type
(web) needs https redirect URIs (400 invalid_redirect_uri otherwise). The client can then ask only for the
scopes listed in its scope: an authorization asking for another comes back with error=invalid_scope.
There is no dynamic client registration: POST /api/auth/oauth2/register answers 403 "Client registration is
disabled".
Public clients only
Every client authenticates with PKCE alone. A client secret, a client assertion or an Authorization header sent to
the token, introspection or revocation endpoint is refused with 401:
{"error":"invalid_client","error_description":"Only public clients are supported: send client_id with PKCE, no client credentials"}What a public client sends to each endpoint:
| Endpoint | Body (form-encoded) |
|---|---|
Token, grant_type=authorization_code | code, redirect_uri, client_id, code_verifier, resource |
Token, grant_type=refresh_token | refresh_token, client_id, resource |
Revocation (/oauth2/revoke) | token, token_type_hint=refresh_token, client_id: 200, and the refresh token stops working. An access token cannot be revoked (see How access ends). |
Introspection (/oauth2/introspect, not in the metadata) | None: it needs client credentials, which PilotFox refuses, so every call is 401 invalid_client. Read the access token's claims instead; it is a signed JWT. |
The flow
The client asks for authorization
It sends the person's browser to the authorization endpoint with a PKCE challenge (S256) and the MCP endpoint as
the resource (RFC 8707):
https://pilotfox.app/api/auth/oauth2/authorize
?response_type=code
&client_id=https%3A%2F%2Fassistant.example.com%2Foauth%2Fclient-metadata.json
&redirect_uri=http%3A%2F%2F127.0.0.1%3A33418%2Fcallback
&scope=openid%20offline_access%20site%3Aread%20analytics%3Aread
&state=af0ifjsldkj
&code_challenge=E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM
&code_challenge_method=S256
&resource=https%3A%2F%2Fpilotfox.app%2Fapi%2Fmcpresource matters: without it the client gets an opaque access token, which /api/mcp refuses (401 "Invalid
access token"). A request without a PKCE challenge comes back with error=invalid_request.
The person signs in
If they are not signed in to PilotFox in that browser, they see the sign-in page first, then continue.
The person chooses an organization
A token is bound to one organization. The Choose an organization page appears when the person belongs to more than one organization (at every authorization, so the choice is always theirs), or when their session has no organization they belong to. Someone who belongs to exactly one organization skips it.
The person approves the permissions
The consent page shows the client's name (and its logo, site, terms and privacy links when its metadata has them),
the permissions it asks for, and Allow or Deny. Deny sends the browser back to redirect_uri with
error=access_denied. A client the person already approved for at least these permissions, in that organization,
goes straight on without asking again.
The client gets its tokens
The browser comes back to redirect_uri with code, state and iss. The client exchanges the code:
curl https://pilotfox.app/api/auth/oauth2/token \
-H "Content-Type: application/x-www-form-urlencoded" \
--data-urlencode grant_type=authorization_code \
--data-urlencode code="$CODE" \
--data-urlencode redirect_uri=http://127.0.0.1:33418/callback \
--data-urlencode client_id=https://assistant.example.com/oauth/client-metadata.json \
--data-urlencode code_verifier="$CODE_VERIFIER" \
--data-urlencode resource=https://pilotfox.app/api/mcpThe answer has access_token, token_type: "Bearer", expires_in: 3600, expires_at and the granted scope, plus
a refresh_token when offline_access was granted and an id_token when openid was. A code works once: presenting
it again is invalid_grant, and also revokes the refresh token it gave.
The client calls the MCP server
Every request carries Authorization: Bearer <access_token>. When the access token expires, the client uses its
refresh token (grant_type=refresh_token, with client_id and resource); each refresh returns a new refresh
token, and the organization stays the same.
Scopes
| Scope | Gives |
|---|---|
openid, profile, email | Sign-in information for the client (an ID token). No access to analytics. |
offline_access | A refresh token. |
site:read, analytics:read, sessions:read, users:read, goals:read, funnels:read, segments:read, revenue:read | The read tools that need them. |
goals:write, funnels:write, annotations:write | The write tools that need them. |
The tool reference names the permission each tool needs. Only these eleven permission scopes grant anything on the MCP server.
What a token can do
A token acts as its person, in the organization it is bound to (its org_id claim), with the scopes it was granted
intersected with the person's current role in that organization. The role is read again on every request, so a
promotion, a demotion or a site grant counts at once.
- Tools: the server lists the tools whose permission is in both the token's scopes and the person's role. A viewer
never gets a write tool, even with
goals:writein the token's scopes: viewers hold no write permission. Members, editors, admins and owners hold all three MCP write permissions. - Restricted members (members who see only the sites granted to them) get a viewer's tool list, so no write tool, and reach only the sites granted to them.
- Sites: the organization's sites the person can see. Their own sites in another organization answer
not_found: bind another token to that organization to reach them. A site made public by its owners is open to anyone, through these tools too. - Segments: the organization's shared segments and, with
segments:read, the person's own private segments. - REST: none. An MCP access token authenticates
/api/mcponly; the REST API (/api/v1) answers it401.
See Permissions and scopes for what each role holds.
Token checks
Access tokens are signed JWTs (typ: at+jwt), valid for one hour. On every request the MCP server checks:
- The signature, against the instance's own key set; a token whose key id is unknown is refused at once.
issis the instance's authorization server,audincludeshttps://pilotfox.app/api/mcp, andexphas not passed.- The token is still live: the PilotFox session it was issued from still exists and has not expired, the person is not banned, and the client exists and is not disabled.
- The person is still a member of the token's organization.
| Failure | Answer |
|---|---|
| Invalid, expired or foreign token (steps 1 and 2) | 401, with the WWW-Authenticate challenge |
| Not live any more (step 3) | 401 "Access token no longer valid" |
| No longer a member (step 4) | 403 "Organization access revoked" |
| The key set could not be read | 503 "Authorization temporarily unavailable" |
How access ends
| Event | Access token | Refresh token |
|---|---|---|
| The person signs out of the session they approved the client in, or that session expires or is revoked | Refused at once (401) | Refused (invalid_grant) |
| The person is banned | Refused at once (401) | Refused once the ban ends their sessions |
| An administrator disables the client | Refused at once (401) | Refused |
| The person leaves the organization or is removed from it | Refused at once (403) | Revoked, and the consent deleted: invited back, they are asked again |
| The organization is deleted | Refused at once (403) | Deleted with its consents |
| The person disconnects the client (below) | Keeps working until it expires (at most one hour) | Revoked |
The client revokes its refresh token (/oauth2/revoke) | Keeps working until it expires | Revoked |
Because the token lives and dies with the PilotFox session it came from, signing out of PilotFox in that browser disconnects every MCP client authorized from it.
To disconnect one client without signing out, the person lists their approvals with
GET /api/auth/oauth2/get-consents and deletes one with POST /api/auth/oauth2/delete-consent and {"id": "…"},
both with their signed-in session. That revokes the client's refresh tokens for that organization; an access token
already issued cannot be revoked and expires within the hour.
Limits
120 MCP requests a minute per person and client, and each tool call counts against the REST budget of the same principal. See Limits.