> ## Documentation Index
> Fetch the complete documentation index at: https://docs.firstflow.app/llms.txt
> Use this file to discover all available pages before exploring further.

# API keys

> Why MCP clients use OAuth instead of static API keys, where each credential is valid, and how the /mcp endpoint enforces the split.

FirstFlow has two credential paths, and the line between them is enforced at the
`/mcp` endpoint. **MCP clients authenticate with an OAuth access token; static
API keys (`ff_live_…`) authenticate the REST API and the [server SDK](/server/overview).**
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:

1. **JWT first.** If the token is an in-product session JWT (the dashboard or an
   internal tool calling `/mcp`), it is accepted directly.
2. **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 prefixed
   `oauth:`. A key you created by hand under **Settings → API keys** has neither,
   so it is rejected with a `401`.

That rejection is not a generic failure. It is a [RFC 9728](/mcp/quickstart)
`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.

```text theme={null}
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="MCP", error="invalid_token",
  error_description="API-key MCP auth is disabled. Use the OAuth flow to connect.",
  resource_metadata="https://api.firstflow.app/.well-known/oauth-protected-resource",
  as_uri="https://api.firstflow.app/.well-known/oauth-authorization-server"
```

The diagram below shows the full decision path inside the `/mcp` handler.

```mermaid theme={null}
flowchart TD
    A[Request to /mcp<br/>Authorization: Bearer token] --> B{Bearer token<br/>present?}
    B -->|no| R1[401 + WWW-Authenticate<br/>resource_metadata]
    B -->|yes| C{Valid session JWT?}
    C -->|yes| OK[Resolve scopes from role<br/>check quota, serve tools]
    C -->|no| D{Valid API key?}
    D -->|no| R1
    D -->|yes| E{issuedVia = mcp-oauth<br/>or name starts oauth:?}
    E -->|yes| OK
    E -->|no - static key| R2[401 invalid_token<br/>+ as_uri to start OAuth]

    style OK fill:#1f9d55,stroke:#0b5a2e,color:#fff
    style R1 fill:#c0392b,stroke:#7a241b,color:#fff
    style R2 fill:#d35400,stroke:#8a3a00,color:#fff
    style E fill:#8e44ad,stroke:#5b2c6f,color:#fff
```

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](/mcp/scopes) for
the exact mapping and how legacy coarse scopes expand.

## Where each credential belongs

Use this to decide which credential a given integration needs.

| Credential             | Format              | Where it authenticates                        | How you get it                                                      |
| ---------------------- | ------------------- | --------------------------------------------- | ------------------------------------------------------------------- |
| **OAuth access token** | minted, client-held | the `/mcp` endpoint only                      | the [OAuth + PKCE flow](/mcp/quickstart), approved in the dashboard |
| **Static API key**     | `ff_live_…`         | REST API + the [server SDK](/server/overview) | created under **Settings → API keys**                               |

Static API keys are what you reach for when *your own backend* talks to
FirstFlow:

* the [server SDK](/server/configuration) 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.

For browser code, neither credential applies: the React SDK uses a
browser-safe `publishableKey` (`pk_live_…`), which is not a secret and is not
accepted on `/mcp` or the REST API. See the [provider reference](/react/provider)
for that path.

<Warning>
  The static API key (`ff_live_…`) is a server secret. It grants REST and server
  SDK access for your whole organization, so keep it in server-side environment
  variables only. The browser SDK never needs it use `publishableKey` there.
</Warning>

## Managing API keys

Create, scope, and revoke static API keys under **Settings → API keys**.

<Steps>
  <Step title="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.
  </Step>

  <Step title="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.
  </Step>

  <Step title="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.
  </Step>
</Steps>

## Specific cases

<AccordionGroup>
  <Accordion title="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.
  </Accordion>

  <Accordion title="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.
  </Accordion>

  <Accordion title="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](/self-hosting/configuration) for the full
    environment reference.
  </Accordion>
</AccordionGroup>

## Next

Connect a coding agent through the OAuth flow in the [MCP quickstart](/mcp/quickstart),
review how permissions map to roles in [scopes & roles](/mcp/scopes), or wire a
static key into your backend with the [server SDK configuration](/server/configuration).
