On this page

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 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, 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 keyUser tokenOAuth 2.1 access tokenSDK session
Formfab_sk_… opaque stringHS256 JWTHS256 JWT, token_use: "access"HS256 JWT, token_use: "session", plus a fab_srt_… refresh token
Issued byPOST /v1/apikeys on this gatewayThe platform, at sign-inPOST /oauth/token on this gatewayPOST /v1/auth/login on this gateway
Verified byHash lookup in the key storeSignature check against the shared secretSignature check against the shared secretSignature check, then the session and the user's membership
ScopePinned at creation; never widensThe session's own claims, with header fallbacksThe scope consented to at authorizationOne project, as the logged-in user with their current role
ExpiryNone — revocation onlyThe session's exp3600 seconds, refreshable for 30 days3600 seconds, refreshable for 30 days
Reports itself asauth_kind: "api_key"auth_kind: "user_jwt"the access-token scopeauth_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.

CredentialGrants returned
API key (no explicit grants requested)query.read, docs.read, ontology.write, orchestrate.read, orchestrate.write, agent.run, skill.write, model.write
User tokenquery.read, ontology.write, docs.read, catalog.read, catalog.write, agent.run
OAuth access tokenexactly 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 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:

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 covers how they are configured, and 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.

RouteHow it is authenticatedWhy
GET /healthzNothingA liveness probe runs before anything holds a credential.
GET /v1/connection-infoNothingIt is what a client reads in order to authenticate. No secrets are in it.
GET /.well-known/oauth-protected-resourceNothingThe 401 challenge points at it, so requiring a credential would be circular.
GET /.well-known/oauth-protected-resource/mcp/{projectId}NothingThe project-scoped variant of the same document.
GET /.well-known/oauth-authorization-serverNothingStandard authorization-server metadata.
POST /oauth/registerNothingDynamic 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 /sseNothingRetired paths answering 410 Gone before authentication runs, so the answer is identical with or without a valid credential.
POST /v1/codesync/webhook/githubProvider signature — HMAC-SHA256 over the raw body in X-Hub-Signature-256GitHub cannot hold a Wexa credential; it signs instead.
POST /v1/catalog/om/webhookProvider signature — HMAC-SHA256, verified inside the webhook handlerSame 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} segmentThe 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 signatureThe 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}NothingThese 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/ingestShared-secret header — x-server-key must equal the gateway's configured server keyA 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

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:

Www-Authenticate: Bearer resource_metadata="https://fabric.wexa.ai/.well-known/oauth-protected-resource/mcp/proj_abc123"
BodyMeaning
missing bearer credentialNo Authorization: Bearer header at all.
api key invalid or revokedThe credential matched fab_sk_ but no live key.
token invalidNot 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.