Skip to main content

API keys for the Model Gateway — who can mint one, what it is scoped to, and how to revoke it

Audience: members of an organisation, and whoever supports them. If you are connecting an agent that runs outside UpsQuad, you want Connecting an external agent to the Model Gateway instead — external agents do not use the keys on this page, and §1 says why.

Read this first — the unit is REQUIRED, and it is what makes the key work​

A member API key must be scoped to an org unit you belong to. unit_id is a required field on the mint request; there is no default and the server will not guess one.

That requirement is the whole of #3416, and the reason it is phrased as a hard requirement rather than a nicety is a measurement. Before it landed the unit came from a JWT claim the live Clerk session template does not emit, so every key ever minted through the portal carried an empty unit — and an empty unit is not degraded attribution, it is a 403 binding_unit_unresolved at gate 5/binding on every single call. Measured on beta-dev 2026-09-23: the plain member key refused, and the same key with a unit id written into its grain admitted, relayed, and recorded with llm_usage_events.governance_outcome = allow. One field was the entire difference.

So a key with no unit is not a weaker key. It is a key that cannot complete a call, which is why minting one is now refused up front (400 UNIT_REQUIRED) at the moment you can still fix it.

One key, one unit. If you work in two units, mint two keys — see §3.

Re-verify rather than re-trust: the end-to-end claim above is reproduced in CI by TestMemberKeyUnit3416_AMintedKeyIsAdmittedAndMeteredAtTheEdge (internal/modelgateway/member_key_unit_3416_test.go), which mints through the production path and drives the production gate chain, with TestMemberKeyUnit3416_AnEmptyUnitIsStillRefusedAtGate5 as its control.


1. A key is a MEMBER credential. An agent credential is something else.​

This is the distinction the whole page rests on, and it is the answer to "how does my agent get an access key?"

CallerCredentialWhere it comes from
A member (you, curl, a CI job acting as you)uq_key_… API key — this pagethe portal's API Keys page / APIKeyService.CreateAPIKey
A platform-run agentshort-TTL agent-scoped bearer, minted per stepthe runtime; nothing for you to hold
An external / third-party agentan external_agent_clients client id + secret, exchanged for a ~10-minute token at /oauth/tokenexternal agent onboarding §3

An API key cannot be made into an agent credential. The scoped_agent_id field still exists on the mint request, and a key carrying it still stores, but the gateway refuses it at gate 3/identity with agent_scoped_key_demoted — the uq_key_* deprecation window closed in T17 (#2919, merged #3068). Read the switch off the process you are talking to, never off this page:

docker logs upsquad-model-gateway | grep -o 'agent_scoped_keys=[a-z]*'
# agent_scoped_keys=refused <- beta-dev, 2026-09-23

refused is not universal. The estate is deliberately split on this switch and the GKE base ConfigMap is still accepted pending its own inventory run — scripts/check-model-gateway-exposure.py prints the per-manifest position on every run, which is the only answer that cannot go stale.

The durable thing a third-party agent holds is therefore its client secret, not a key from this page. That is not a workaround; it is the design. A ten-minute token that the holder re-grants is a smaller blast radius than a bearer that never expires, and its runtime = "external" provenance is stamped onto every ledger and audit row.


2. Who may mint​

Clearance L3 or above, server-enforced. apikey.MinMintClearance is the single copy of that floor (internal/gateway/apikey/mint.go). It is checked in the mint path, so it applies to the portal, to curl, and to anything else that reaches the RPC.

Three things a minted key takes from the verified caller and never from the request body (#3379):

  • the member it is attributed to — if the server cannot say who you are, it refuses (CALLER_UNRESOLVED) rather than minting a key it cannot attribute;
  • the organisation;
  • the clearance, taken as the minimum of every verified source and clamped to [1,5]. A key can never carry more authority than the member who asked for it.

team_id and member_id in the request body are parsed and ignored (they are marked deprecated in the proto). Sending someone else's is not an error, but it is logged as ignored.

unit_id is the one identity-adjacent field you choose, and the server checks it (#3416). The mint is refused unless you hold an active membership of that unit, of kind team, in your own org — read from org_unit_memberships joined to org_units, inside the request's own RLS-scoped transaction. That is not a re-opening of #3379's forgery: the wire may only select among authorities you already have, which is structurally what scoped_agent_id does. Your role in the unit is not consulted — an ordinary member of a unit is exactly who is meant to hold a durable credential for it.

The four refusals are distinct on purpose, because each sends you somewhere different:

CodeHTTP / ConnectWhat to do
UNIT_REQUIRED400 / InvalidArgumentname a unit you belong to
UNIT_NOT_UUID400 / InvalidArgumentunit_id must be a uuid — _default is not a unit
UNIT_NOT_A_MEMBER403 / PermissionDeniedyou are not an active member of that unit or it does not exist in your org — the two are deliberately indistinguishable, so that walking uuids tells you nothing
UNIT_VERIFIER_UNAVAILABLE500 / Internalnot your fault: the server could not ask the membership question. Nothing was minted. Escalate, do not retry in a loop

Rotation (APIKeyService.RotateAPIKey, REST POST /v1/api-keys/rotate) is a mint: the same L3 floor applies, you must own the key, and the replacement's clearance is capped by both yours and the old key's. Rotation replaces a credential — it does not re-specify one, so the scoped agent, the expiry and the unit are carried over from the key being replaced. The carried unit is re-verified: if you have left the unit since the key was minted, the rotation is refused UNIT_NOT_A_MEMBER rather than handing you a fresh credential for a unit you are no longer in. A legacy key minted before #3416 carries no unit at all and refuses with UNIT_REQUIRED — mint a fresh unit-scoped key instead of rotating a dud into another.


3. What the key is scoped to​

DimensionSet byNotes
Organisationthe verified callernever empty — migration 237 has a non-empty CHECK
Memberthe verified callerthe attribution grain; every ledger row carries it
Org unit (team)unit_id on the request, verified against your membershipsrequired. This is the grain gate 5/binding resolves against, and the key works or does not work on it. Stored in the member_api_keys.team_id column — the name is historical; migration 256 gives it a non-empty CHECK for new rows
Clearancethe verified caller, min-of-sources, clamped to [1,5]gate 7a compares it against each endpoint's min_clearance
Agentscoped_agent_id, optionalcan only NARROW a key. Refused at the gateway where agent_scoped_keys=refused — see §1
Expiryexpires_at, optional, RFC 3339no default and no maximum. Omitting it mints a key that never expires. Set one

One key is bound to ONE unit, and that is a design decision rather than a limitation. The gateway routes by model first and only then resolves the binding for (routed endpoint, your unit), so a key pinned to a single endpoint would either refuse every other approved endpoint in your unit or re-implement the binding table inside the credential. The binding table is already the per-endpoint authority, approved per unit. A member of two units mints two keys.

A key is a bearer credential: anyone holding the string is the member it names, at that member's clearance, until it expires or is revoked. Treat it like a password — the platform stores only a SHA-256 hash and cannot show it to you again after the mint response.


4. Minting a key, then making a call​

Step 1 — find a unit you belong to. The portal's API Keys page offers your own memberships; from a shell, any unit id you already know you are in will do. It must be a team unit, not a pillar.

Step 2 — mint. REST, against the context engine (:8080, :8083 on the devbox):

curl -s -X POST "$CE/v1/api-keys" \
-H "Authorization: Bearer $CLERK_JWT" \
-H 'Content-Type: application/json' \
-d '{"unit_id":"<a team unit you are a member of>",
"expires_at":"2026-12-31T23:59:59Z"}'
# -> 201 {"key_id":"…","raw_key":"uq_key_…","key_prefix":"uq_key_…"}

The same request over Connect is upsquad.auth.v1.APIKeyService/CreateAPIKey with {"unitId": "…"} — that is the RPC the portal calls, and it enforces the identical rules (both surfaces go through one Mint, with a parity test that reddens if they ever diverge).

raw_key is shown once. The platform stores a SHA-256 hash and cannot recover it.

Step 3 — call the gateway. The call shape is the same one the external-agent page documents — only the credential differs:

# PUBLIC (live since 2026-09-07, #2912): MG=https://model-gw-beta.upsquad.ai
# DEVBOX LOOPBACK: MG=http://127.0.0.1:8091

curl -s -X POST "$MG/v1/chat/completions" \
-H "Authorization: Bearer $UQ_KEY" \
-H 'Content-Type: application/json' \
-d '{"model":"<a model id on an endpoint your unit is bound to>",
"messages":[{"role":"user","content":"hello"}]}'

Your Authorization header is stripped before the request is forwarded and the tenant's own provider credential is injected. Everything under /v1/ is relayed; the gateway does not enumerate upstream routes. For dialect inference, translation, the credential-grain walk and every refusal code, read external agent onboarding §4 — those are properties of the gateway, identical for both credential types, and a second copy here would be the half that goes stale.

Three refusals are specific to this credential and worth recognising:

WireMeaning
403 no_approved_binding · 5/bindingyour unit has no approved binding for the endpoint serving that model. Create one and have the unit's Manager approve it (MG-2.1). This is the expected refusal for a correctly-scoped key aimed at the wrong endpoint
403 binding_unit_unresolved · 5/bindingyour key carries no org unit at all. Since #3416 only a key minted before it can be in this state — mint a new one; rotation will not fix it
403 agent_scoped_key_demoted · 3/identityyour key names an agent. Use the OAuth path — §1

The difference between the first two is the thing to read carefully: no_approved_binding means the credential is fine and the authorisation is missing; binding_unit_unresolved means the credential itself cannot be reasoned about.


5. Revoking — and why you never delete​

DELETE /v1/api-keys/{id} revokes; it does not remove the row. APIKeyService.RevokeAPIKey is the same operation. Revocation sets is_active = false and leaves everything else in place.

That is deliberate. A member_api_keys row holds a hash, never the secret, and it is the audit record that says this credential existed, who held it, at what clearance and when. Deleting it destroys the only explanation for the llm_usage_events rows that reference it — spend with no attributable credential. Revoke; never delete.

Revocation is immediate, and that is newer than it should be. Until #3370 both revoke surfaces deactivated the row and left the resolver's 60-second cache entry alone, so a revoked key kept authenticating for up to a minute. Measured on beta-dev 2026-09-23: revoke at t+0, is_active = false in the database, and the call at t+1s admitted and relayed; the call at t+65s finally 401. Both surfaces now evict the cache entry as part of the same operation (internal/gateway/apikey/revoke.go).

If the eviction itself fails you are told, not congratulated: REST answers 503 REVOKE_CACHE_LIVE and the RPC answers CodeUnavailable, both saying the key is deactivated but may be honoured for up to 60 seconds. Retry — the revoke is already applied, retrying is safe, and the retry re-attempts the eviction.

Rotation revokes the old key in the same transaction as it mints the replacement and has always evicted the replaced key's cache entry.

Listing (GET /v1/api-keys, APIKeyService.ListAPIKeys) returns every key in your org, active and revoked, with the prefix but never the secret.


6. What this page does not claim​

  • That a key works without an approved binding. Minting is only half of it. A key scoped to your unit still needs that unit to hold an approved binding to the endpoint serving the model you ask for (llm_endpoint_units.approval_status = 'approved'), or the call is refused no_approved_binding at the same gate. #3416 fixed the credential, not the grant.
  • That issuance is discoverable. The API Keys page is a standalone page with no link from, or reference to, the Model Gateway tab, and it shows no endpoint hostname and no usage snippet. That half of #3370 is upsquad-client work and is not delivered here — including the unit selector the mint dialog now needs, since unit_id is required and the portal is the surface that knows your memberships.
  • That the gateway is carrying production tenant traffic. It is not — the hostname is released for E2E testing under the scoped waiver on #2912. The standing list of gates is the query label:prod-enablement is:open, not a copy on this page.
  • Anything about spend visibility. llm_usage_events records every call and has no consumption surface in either portal — #3009.