Skip to content
Loading SpecStep…
On this page

Authentication

Updated 2026-05-30

Need an account first? API keys are minted from the in-app Settings panel. Sign up (free tier: 5 generations / month) → Settings → API keys → "Create API key". The key string is only shown once at creation time, so copy it somewhere safe.

SpecStep supports two authentication schemes — both send a bearer token in the Authorization header:

  • API keys (sf_…) — the standard scheme for REST callers, CI pipelines, and any headless automation.
  • OAuth 2.1 browser sign-in (oat_…) — MCP-specific. Clients like Claude Desktop, Claude.ai, Cursor, Codex, GitHub Copilot, Continue, and Cline sign you in via a browser; the client never handles a key string. See MCP server for the full discovery and flow walkthrough.

The rest of this page covers the API-key path. The OAuth path is documented in mcp.md — it applies only to the /mcp surface.

Get your first key

Create your first key from Settings → API keys while signed in to specstep.com. The page mints the key, shows you the raw value once, and lets you copy it. Save the raw value to a secrets manager or environment variable immediately — SpecStep stores only a SHA-256 hash and cannot show it again.

Once you have a key, use it on every REST and MCP request as Authorization: Bearer sf_xxxxxxxxxxxx. See Sending the key below.

Why not just POST /v1/api-keys with my new account? The REST endpoint that mints keys is locked to authenticated browser sessions — it refuses API-key bearer tokens. The reasoning is direct: a leaked API key must not be able to mint a replacement and survive revocation. So the very first key has to come from the Settings UI; subsequent keys can be minted via the API from an authenticated session (or kept in Settings, which is simpler). See Only humans may operate the key surface below.

Programmatic key creation (advanced)

From an authenticated browser session — typically the SpecStep web app or an MCP/REST script that has already established a cookie — you can mint additional keys via the API. Call POST /v1/api-keys with a display name. The response includes the raw key value exactly once; it is not stored and cannot be retrieved again. (API-key bearer tokens cannot call this endpoint — see Only humans may operate the key surface.)

POST /v1/api-keys HTTP/1.1
Host: specstep.com
Cookie: <browser session cookie>
Content-Type: application/json

{
  "name": "ci-pipeline"
}
{
  "id": "01952fcb-cd11-7c3e-9a2e-3b1d8f5e6a04",
  "prefix": "abcd1234",
  "raw_key": "sf_abcd1234xxxxxxxxxxxxxxxxxxxxxxxx",
  "created_at": "2026-05-03T12:00:00Z"
}

Copy the raw_key value and store it in a secrets manager or environment variable immediately. SpecStep stores only a SHA-256 hash of the key; if you lose it, delete the key and create a new one. The prefix is the public part of the token (the eight characters after sf_) — safe to log or include in audit trails.

The request body also accepts an optional scopes array to narrow what the key can do — see Per-key scopes below. Omit it to produce a legacy unscoped key.

Key format

Every SpecStep API key starts with sf_. The prefix is public — it identifies the token as a SpecStep key without revealing anything about the account it belongs to. The characters after the prefix are the secret.

Sending the key

Include the key in the Authorization header on every request:

Authorization: Bearer sf_xxxxxxxxxxxx

The scheme must be Bearer, spelled exactly that way. Anything else — Token, ApiKey, a bare value — will not authenticate.

curl https://specstep.com/v1/me \
  -H "Authorization: Bearer ${SPECSTEP_API_KEY}"
import httpx

headers = {"Authorization": f"Bearer {api_key}"}
response = httpx.get("https://specstep.com/v1/me", headers=headers)
const response = await fetch("https://specstep.com/v1/me", {
  headers: { Authorization: `Bearer ${process.env.SPECSTEP_API_KEY}` },
});
client.DefaultRequestHeaders.Authorization =
    new AuthenticationHeaderValue("Bearer", apiKey);

Listing and inspecting keys

GET /v1/api-keys returns all active keys on your account. The raw key value is never returned — only the id, name, prefix, and created_at. Use these to identify which key is which when rotating.

Each summary includes a scopes field — an array of permission codes for scoped keys, or null for legacy unscoped keys — and a revoked_at timestamp (non-null for revoked keys; the list returns both active and revoked keys, so filter on revoked_at if you only want live ones).

Per-key scopes

A key can be restricted to a subset of the permissions its owning account holds. Scopes are permission codes drawn from the catalog at GET /v1/permissions (e.g. generations.write, packages.read).

Pass scopes on POST /v1/api-keys to narrow at create time:

{
  "name": "ci-pipeline",
  "scopes": ["generations.write", "packages.read"]
}

The key's effective permissions are the intersection of the user's role-granted permissions and the requested scopes — never broader. Omitting scopes (or sending null) produces a legacy unscoped key; the auth handler still narrows the claim set automatically, so a key can never exceed what the user account holds. Unknown codes return 400 with a problem-details body listing the unrecognized values, so typos surface at create time rather than producing a key that silently drops claims.

To rotate the scope set on an existing (non-revoked) key without re-issuing it, call PATCH /v1/api-keys/{id}/scopes:

PATCH /v1/api-keys/01952fcb-cd11-7c3e-9a2e-3b1d8f5e6a04/scopes HTTP/1.1
Content-Type: application/json

{
  "scopes": ["generations.read"]
}

Send {"scopes": null} to reset to legacy unscoped. Returns 204 No Content on success, 404 if the key isn't yours (or doesn't exist, or is revoked — the response doesn't distinguish), 400 for unknown codes.

Project scoping

Beyond per-permission scopes, a key can be bound to a single project. Pass project_id on POST /v1/api-keys:

{
  "name": "ci-pipeline",
  "project_id": "01952fcb-cd11-7c3e-9a2e-3b1d8f5e6a04"
}

The project must be one you own or one in your organization, otherwise the create returns 400. Omit project_id (or send null) to leave the key able to reach all of your projects. If your account belongs to an organization, the key is automatically bound to that organization — there is no request field for it. The create response and the GET /v1/api-keys summaries echo both project_id (the bound project, or null) and organization_id (the bound organization, or null) so you can confirm the scoping the server applied.

Session state and project tools

Build sessions, the decision log, the backlog, and project management are available as REST endpoints and MCP tools — any authenticated account can reach them for its own data, no special role required. Access is opt-in per key: scope a key to the capabilities you want it to carry.

Scope Grants
projects.read List and read your projects
projects.write Create, rename, set-default, and archive your projects
session_state.read Read your build sessions, decision-log entries, and backlog items
session_state.write Append decisions, file backlog items, start and end build sessions

session_state.write must be granted explicitly. A legacy unscoped key (one created without a scopes array) does not carry it, so a leaked broad key can read your session state but never mutate it. Add it to the scopes array at create time — or via PATCH /v1/api-keys/{id}/scopes — to enable writes. The other three codes are not restricted that way: an explicitly scoped key carries them when its scopes list them, and a legacy unscoped key carries them automatically. All four only ever reach your own data.

Records are bound to a project (see Project scoping). A key scoped to a single project_id sees and edits session state only for that one project — never your other projects, and never another account's. A key left unscoped reaches every project you own, plus your organization's if you belong to one. The confinement is absolute: a project-scoped key is narrowed to its bound project even if its owner could otherwise read across projects, and if that project ever stops being accessible to the owner, the key sees nothing at all rather than widening to other projects.

The easiest way to mint a project-scoped key with these capabilities is the SpecStep app: open Projects, select your project, and use the API key card on its page. It creates a key bound to that project and scoped to session_state.read, session_state.write, and projects.read — enough to drive session state for the project. Add projects.write yourself (at create time or via PATCH /v1/api-keys/{id}/scopes) if you also want that key to create, rename, or archive projects. Once a key carries the scopes, the matching MCP tools appear in that client's tools/list; a key without those scopes won't see the tools at all.

Rotating a key's secret

POST /v1/api-keys/{id}/rotate rotates the secret of an existing key in place. There is no request body. The response is 200 with a fresh raw_key — shown once, so copy it immediately — while the key's identity, scopes, and project/organization binding stay the same. The previous secret stops authenticating the moment the new one is issued. Returns 404 if the key isn't yours or has been revoked. The GET /v1/api-keys summaries expose last_rotated_at so you can see when each key was last rotated.

Rotating the secret differs from PATCH /v1/api-keys/{id}/scopes (which changes what the key can do) and from DELETE (which retires the key entirely). Reach for rotation when a secret may have leaked but the key's role in your integration hasn't changed.

Only humans may operate the key surface

POST /v1/api-keys, DELETE /v1/api-keys/{id}, PATCH /v1/api-keys/{id}/scopes, and POST /v1/api-keys/{id}/rotate all return 403 when called with an API-key bearer token. Only cookie- or OIDC-authenticated browser sessions may mint, revoke, rotate scopes on, or rotate the secret of a key. The reasoning is direct: a leaked API key must not be able to mint a replacement, re-issue its own secret, and survive revocation. Scope rotation falls under the same rule — a compromised key cannot widen itself.

Revoking a key

DELETE /v1/api-keys/{id} revokes the key immediately. Any request using that key after deletion will receive a 401. There is no confirmation step and no undo. Revoke a key as soon as you suspect it has been exposed.

The simplest path is the SpecStep web app: sign in, open Settings → API keys, and click "Revoke" on the key row. The endpoint refuses API-key bearer tokens — see Only humans may operate the key surface — so it can't be revoked with the same key you're trying to revoke (an attacker holding a leaked key can't make it disappear). From a script running in an authenticated browser context (uncommon — the UI is enough), send the session cookie instead:

DELETE /v1/api-keys/01952fcb-cd11-7c3e-9a2e-3b1d8f5e6a04 HTTP/1.1
Host: specstep.com
Cookie: .AspNetCore.Cookies=<browser session cookie>

The {id} segment is the key's UUIDv7 (returned as id from POST /v1/api-keys). It is not the same value as the prefix.

Auth-failure throttle

To prevent brute-force guessing of key values, SpecStep applies a per-IP throttle to authentication failures. The default policy is 5 failed attempts in 5 minutes per client IP address.

Only failures count toward the threshold. A request that authenticates successfully, regardless of what it does next, does not increment the counter. Legitimate callers with a valid key will never trip this limit.

When the threshold is crossed, further requests from that IP are rejected without performing a database lookup. The response is 401 with a generic error — the same response returned for an unknown or revoked key. The throttle does not disclose which condition applied.

If you are rotating keys in an automated script and generating failures in the process, slow the rotation loop or add a delay between attempts to stay well below 5 failures per 5 minutes — typically by adding a few seconds of jitter after each attempt, or by checking your local key store before issuing the next request.

The threshold and window are server-side configurable and may be tightened over time. Treat the documented defaults as a floor, not a ceiling.

Authentication errors

Condition Status
No Authorization header 401
Authorization header present but bearer token format not recognized (neither sf_… nor oat_…) 401
Key or token does not exist, has been revoked, or has expired 401
Account owning the key or token is disabled 401
Per-IP throttle threshold exceeded 401
Key-management endpoint (mint / revoke / rotate scopes / rotate secret) called with an API-key bearer token 403

All 401 responses return the same generic message. SpecStep does not disclose which condition applied.