/mcp endpoint. MCP clients authenticate with an OAuth access token; static
API keys (ff_live_…) authenticate the REST API and the server SDK.
They are not interchangeable: a static API key is deliberately rejected on
/mcp, and an OAuth token only exists to authorize MCP traffic.
This page explains why the split exists, how the server tells the two apart on
every request, and where each credential belongs.
How the split is enforced
Every request to/mcp carries Authorization: Bearer <token>. The controller
authenticates manually instead of a single guard so it can return a precise
WWW-Authenticate header that tells a non-authenticated MCP client how to start
the OAuth flow. It resolves the bearer token in two stages:
- JWT first. If the token is an in-product session JWT (the dashboard or an
internal tool calling
/mcp), it is accepted directly. - API key second. Otherwise the token is verified as an API key. Here is
the gate: the key is accepted only if it was issued by the OAuth flow —
its metadata carries
issuedVia: "mcp-oauth"or its name is prefixedoauth:. A key you created by hand under Settings → API keys has neither, so it is rejected with a401.
401 whose WWW-Authenticate header advertises both the protected-resource
metadata and the authorization-server URI, so a compliant client
(Claude Code, Cursor, Claude Desktop) reads it and automatically starts the
OAuth handshake instead of giving up.
/mcp handler.
The practical consequence: you never paste an API key into an MCP client config.
You point the client at the /mcp URL and approve a consent screen. The token
the client ends up using is minted by FirstFlow, lives only in the client’s
secure store, and carries the oauth: marker the gate looks for.
Why MCP uses OAuth, not a static key
A static API key is a long-lived secret with a fixed scope set. That model fits a backend you control, but it is a poor fit for a coding agent: the key would sit in a config file on a developer laptop, every teammate would share or re-create one, and revoking access would mean rotating a secret across machines. OAuth + PKCE replaces that with a per-client, per-user, revocable grant. The user signs in, picks the organization, and approves a scoped consent screen, so the resulting token is tied to that person’s organization role and can be revoked from the dashboard without touching anyone else. Scope is not chosen by the client it is derived server-side from the caller’s role (admins get the full catalog; members get read and list only). See Scopes & roles for the exact mapping and how legacy coarse scopes expand.Where each credential belongs
Use this to decide which credential a given integration needs.
Static API keys are what you reach for when your own backend talks to
FirstFlow:
- the server SDK reads it from
FIRSTFLOW_API_KEY(secret, server-only never ship it to a browser), - direct REST calls from your backend pass it as a bearer token.
publishableKey (pk_live_…), which is not a secret and is not
accepted on /mcp or the REST API. See the provider reference
for that path.
Managing API keys
Create, scope, and revoke static API keys under Settings → API keys.1
Create the key
A new key is shown in full exactly once, at creation. Copy it then —
FirstFlow stores only a hash, so it cannot show you the value again.
2
Store it as a server secret
Put it in your backend’s environment as
FIRSTFLOW_API_KEY. Do not commit
it, and do not expose it to client-side code.3
Revoke when rotating or off-boarding
Keys are organization-scoped and revocable. Revoking takes effect
immediately for REST and server SDK calls. It does not affect MCP
clients, which hold OAuth tokens revoke an MCP grant from the OAuth side
instead.
Specific cases
I pasted my ff_live_ key into Claude Code and got a 401
I pasted my ff_live_ key into Claude Code and got a 401
That is expected. Static keys are rejected on
/mcp. Remove the key from the
client config and connect via the URL instead claude mcp add --transport http firstflow <BACKEND_PUBLIC_URL>/mcp then approve the consent screen
when the client opens it. The 401 you saw carried the OAuth metadata that
triggers that flow automatically.Why does the dashboard reach /mcp without OAuth?
Why does the dashboard reach /mcp without OAuth?
In-product callers (the dashboard, internal tools) present a session JWT,
which the controller accepts in its first authentication stage. That path
exists for first-party surfaces only; external MCP clients always go through
OAuth.
Self-hosting: which URLs drive the OAuth metadata?
Self-hosting: which URLs drive the OAuth metadata?
The
401 advertises an issuer derived from OAUTH_ISSUER if set, otherwise
BACKEND_PUBLIC_URL. Set BACKEND_PUBLIC_URL to the backend’s public origin
and FRONTEND_URL to the dashboard so the consent redirect resolves. See the
self-hosting configuration for the full
environment reference.