PilotFoxDocs

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}
DocumentURL on pilotfox.app
Protected-resource metadatahttps://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 discoveryhttps://pilotfox.app/api/auth/.well-known/openid-configuration
Protected-resource metadata
{
  "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:

FieldValue
issuerhttps://pilotfox.app/api/auth
authorization_endpointhttps://pilotfox.app/api/auth/oauth2/authorize
token_endpointhttps://pilotfox.app/api/auth/oauth2/token
jwks_urihttps://pilotfox.app/api/auth/jwks
revocation_endpointhttps://pilotfox.app/api/auth/oauth2/revoke
token_endpoint_auth_methods_supportednone
revocation_endpoint_auth_methods_supportednone
grant_types_supportedauthorization_code, refresh_token
code_challenge_methods_supportedS256
client_id_metadata_document_supportedtrue

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 URLhttps, 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 fetchNo redirects followed, at most 5 seconds, a JSON content type, at most 5 KiB.
client_idExactly the document's own URL.
client_nameRequired, not empty: the consent page shows it.
redirect_urisRequired, 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 fieldsServer-side fields such as client_secret, client_secret_expires_at, skip_consent or require_pkce.
client_uri, post_logout_redirect_urisWhen present, on the same origin as the client_id URL.
https://assistant.example.com/oauth/client-metadata.json
{
  "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:

EndpointBody (form-encoded)
Token, grant_type=authorization_codecode, redirect_uri, client_id, code_verifier, resource
Token, grant_type=refresh_tokenrefresh_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%2Fmcp

resource 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/mcp

The 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

ScopeGives
openid, profile, emailSign-in information for the client (an ID token). No access to analytics.
offline_accessA refresh token.
site:read, analytics:read, sessions:read, users:read, goals:read, funnels:read, segments:read, revenue:readThe read tools that need them.
goals:write, funnels:write, annotations:writeThe 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:write in 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/mcp only; the REST API (/api/v1) answers it 401.

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:

  1. The signature, against the instance's own key set; a token whose key id is unknown is refused at once.
  2. iss is the instance's authorization server, aud includes https://pilotfox.app/api/mcp, and exp has not passed.
  3. 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.
  4. The person is still a member of the token's organization.
FailureAnswer
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 read503 "Authorization temporarily unavailable"

How access ends

EventAccess tokenRefresh token
The person signs out of the session they approved the client in, or that session expires or is revokedRefused at once (401)Refused (invalid_grant)
The person is bannedRefused at once (401)Refused once the ban ends their sessions
An administrator disables the clientRefused at once (401)Refused
The person leaves the organization or is removed from itRefused at once (403)Revoked, and the consent deleted: invited back, they are asked again
The organization is deletedRefused 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 expiresRevoked

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.

On this page