> Source: https://wexa.ai/docs/api/authentication

# Authentication

Every authenticated route on the gateway takes one header and nothing else:

```
Authorization: Bearer <credential>
```

Behind that header the gateway accepts four kinds of credential. [REST
authentication](/docs/surfaces/rest/authentication) introduces them and shows which to pick; this
page is the reference behind it — the resolution order, the claims each type carries, the grants
each one actually ends up with, and the complete list of routes that take no credential at all.

 Hosts are written as `https://fabric.wexa.ai`; substitute your own issuer.

## How a credential is resolved

The gateway does not ask you which kind of credential you are sending. It works it out, in a fixed
order, and the order is worth knowing because it decides which error you get when something is
wrong.

1. **Prefix match.** A credential beginning `fab_sk_` is looked up in the API key store, by SHA-256
   hash. Nothing else is tried.
2. **OAuth access token.** Otherwise the credential is verified as an access token issued by this
   gateway — an HS256 JWT carrying `token_use: "access"`.
3. **User token fallback.** If that fails, it is verified as a Wexa user token: the same HS256
   signature, without the `token_use: "access"` claim. This is the session token the dashboard
   holds.

An SDK session access token, issued by [login](/docs/tools/login), is recognised by its
`token_use: "session"` claim and resolved as the user who logged in, with their role in the project
checked again on every call.

Two consequences follow. A mistyped API key that no longer starts with `fab_sk_` is never looked up
as a key — it falls through to step 2, fails both JWT checks, and comes back as
`{"error":"invalid_token","error_description":"token invalid"}` rather than anything mentioning a
key. And a revoked key, which *does* match the prefix, is refused with a different message:
`api key invalid or revoked`. The two strings are the fastest way to tell "wrong secret" from
"wrong kind of secret".

## The four types side by side

| | API key | User token | OAuth 2.1 access token | SDK session |
|---|---|---|---|---|
| Form | `fab_sk_…` opaque string | HS256 JWT | HS256 JWT, `token_use: "access"` | HS256 JWT, `token_use: "session"`, plus a `fab_srt_…` refresh token |
| Issued by | `POST /v1/apikeys` on this gateway | The platform, at sign-in | `POST /oauth/token` on this gateway | `POST /v1/auth/login` on this gateway |
| Verified by | Hash lookup in the key store | Signature check against the shared secret | Signature check against the shared secret | Signature check, then the session and the user's membership |
| Scope | Pinned at creation; never widens | The session's own claims, with header fallbacks | The scope consented to at authorization | One project, as the logged-in user with their current role |
| Expiry | None — revocation only | The session's `exp` | 3600 seconds, refreshable for 30 days | 3600 seconds, refreshable for 30 days |
| Reports itself as | `auth_kind: "api_key"` | `auth_kind: "user_jwt"` | the access-token scope | `auth_kind: "session"` |

A user token is issued at sign-in; this gateway issues the other two. That split is the reason a
user token is the credential for key management: it is minted where who you are is known, while an
API key only knows the scope it was stamped with.

## Grants are derived three different ways

This is the part that surprises people. The same person, on the same project, ends up with a
different grant list depending on which credential they are holding. All three of the following were
read back from `GET /v1/whoami` on one gateway, for one `OWNER` on one project.

| Credential | Grants returned |
|---|---|
| API key (no explicit grants requested) | `query.read`, `docs.read`, `ontology.write`, `orchestrate.read`, `orchestrate.write`, `agent.run`, `skill.write`, `model.write` |
| User token | `query.read`, `ontology.write`, `docs.read`, `catalog.read`, `catalog.write`, `agent.run` |
| OAuth access token | exactly the scope consented to — here `query.read`, `docs.read` |

The API key list is the default set for the minting role: eight grants for `OWNER`, `ORG_ADMIN` and
`PROJECT_ADMIN`, and the read-only pair `query.read` + `docs.read` for every other role. The user
token's list is a **fixed six**, assigned by the verification path itself and not varied by role —
which is why it is the only credential of the three that can reach the catalog write endpoints. The
access token's list is whatever the consent screen granted, and nothing more.

Grants gate the *call*, not the listing. A credential that lacks a grant still sees every tool in a
`tools/list` response; the refusal arrives when it calls one, as
`error (S2:resolve-scope): token missing required grant "…"`.

## API key format and storage

A key is `fab_sk_` followed by a random string. Only a SHA-256 hash of the secret is stored, so the
gateway can verify a key it is shown and can never reproduce one it is not. The plaintext exists in
exactly one response body, at creation. There is no recovery endpoint, and there is no rotation
endpoint: a lost key is replaced by minting a new one, migrating callers, and revoking the old one.
[API keys](/docs/api/api-keys) documents that sequence.

## Rate limits on a key-authenticated call

Two different limiters exist, and conflating them leads to the wrong retry behaviour.

**A per-key limit of 60 requests per minute applies to one route only:**
`POST /v1/agents/{agentflowId}/chat/completions`. It is a fixed one-minute window held in the
the gateway, counted per key id, and it is the only place the per-key limiter is consulted.
On a live gateway one key was allowed 60 requests inside the window, and the 61st was refused:

```text
HTTP/1.1 429 Too Many Requests
Retry-After: 1

{"error":{"message":"too many requests for this API key — try again shortly","type":"rate_limit_exceeded","code":"rate_limited"}}
```

Note the body: that route speaks the OpenAI error shape, not Wexa's, and its `Retry-After` is a
constant `1` rather than a computed remainder.

**Everything else is governed by the project and organization quota windows** — 120 requests per
project and 600 per organization in a sliding 60-second window by default, `0` meaning unlimited.
Those produce Wexa's own error body, `"error":"S3:rate-quota"`, with a `Retry-After` carrying the
real seconds remaining. [Quota and credits](/docs/concepts/quota-and-credits) covers how they are
configured, and [errors and retries](/docs/surfaces/rest/errors-and-retries) covers what to do about
either one.

## Routes that take no credential

Some routes are unauthenticated on purpose. They are not all unauthenticated in the same way, and
the distinction matters: three of these verify a secret, they just do not verify a *bearer* secret.

| Route | How it is authenticated | Why |
|---|---|---|
| `GET /healthz` | Nothing | A liveness probe runs before anything holds a credential. |
| `GET /v1/connection-info` | Nothing | It is what a client reads *in order to* authenticate. No secrets are in it. |
| `GET /.well-known/oauth-protected-resource` | Nothing | The `401` challenge points at it, so requiring a credential would be circular. |
| `GET /.well-known/oauth-protected-resource/mcp/{projectId}` | Nothing | The project-scoped variant of the same document. |
| `GET /.well-known/oauth-authorization-server` | Nothing | Standard authorization-server metadata. |
| `POST /oauth/register` | Nothing | Dynamic client registration is the mechanism by which an unknown client becomes known. An optional `x-server-key` header marks a first-party registration. |
| `POST /mcp`, `GET /mcp`, `DELETE /mcp`, `GET /sse` | Nothing | Retired paths answering `410 Gone` before authentication runs, so the answer is identical with or without a valid credential. |
| `POST /v1/codesync/webhook/github` | **Provider signature** — HMAC-SHA256 over the raw body in `X-Hub-Signature-256` | GitHub cannot hold a Wexa credential; it signs instead. |
| `POST /v1/catalog/om/webhook` | **Provider signature** — HMAC-SHA256, verified inside the webhook handler | Same reason, for catalog change events. |
| `POST /actions/linkedin/notify/{connectorID}/{pin}`, `POST /actions/mail/notify/{connectorID}/{pin}`, `POST /actions/whatsapp/notify/{connectorID}/{pin}` | **Path secret** — the `{pin}` segment | The provider was handed this URL and nothing else; the pin is the shared secret, and it is checked downstream. |
| `POST /unipile-webhook/{projectID}/{connectorID}` | **Nothing** — no pin, no signature | The provider signs nothing. Authenticity is re-established downstream, which re-fetches the reported account from the provider before trusting it. |
| `POST /blandai/{triggerID}`, `POST /falai/{triggerID}` | **Nothing** | These providers send no verifiable credential either. The trigger id is resolved against registration records downstream, and an unknown one is refused there. |
| `POST /v1/internal/catalog/ingest` | **Shared-secret header** — `x-server-key` must equal the gateway's configured server key | A service-to-service route, never called by a customer. Unset key, or wrong key, answers `401 unauthorized`. |

One route is authenticated but not by that header. `POST /oauth/authorize` reads the user token from
a `user_token` **form field**, falling back to the `Authorization` header only if the field is
empty — because it is posted by a browser consent screen, not by an API client. Sent without either,
it answers `401 consent requires a valid Fabric user token`.

## What a refusal looks like

```text
HTTP/1.1 401 Unauthorized
Www-Authenticate: Bearer resource_metadata="https://fabric.wexa.ai/.well-known/oauth-protected-resource"

{"error":"invalid_token","error_description":"missing bearer credential"}
```

On a project-scoped connect URL the challenge names the project-scoped document instead, so a
client that follows it discovers the right project without being told:

```text
Www-Authenticate: Bearer resource_metadata="https://fabric.wexa.ai/.well-known/oauth-protected-resource/mcp/proj_abc123"
```

| Body | Meaning |
|---|---|
| `missing bearer credential` | No `Authorization: Bearer` header at all. |
| `api key invalid or revoked` | The credential matched `fab_sk_` but no live key. |
| `token invalid` | Not a key, and not a valid token of either JWT kind. Also what an API key gets on a route that requires a user token. |

## Related

  <Card title="API keys" href="/docs/api/api-keys">
    Creating, listing and revoking `fab_sk_` credentials.
  </Card>
  <Card title="OAuth 2.1" href="/docs/api/oauth">
    Registration, authorization, token, and both discovery documents.
  </Card>
  <Card title="Identity" href="/docs/api/identity">
    The endpoint that echoes your resolved scope back to you.
  </Card>
  <Card title="REST authentication" href="/docs/surfaces/rest/authentication">
    The narrative version: which credential to pick and why.
  </Card>
