Skip to content

Provider credentials

A provider credential is a provider API key that the gateway uses to call a provider on behalf of your application. Credentials belong to an organization; its projects’ routes pick them as targets. Your applications only ever hold a gateway key, never the provider key.

Only organization owners can add, rotate, check or delete a credential. Any member can list them, and the list never contains the key. Manage them in Gateway, Credentials. Each credential shows its ID under its name, with a copy button; you need the ID for the eval gate and for routes created through the API.

Before you start

  • The owner role in the organization. Every role can list credentials, and the list never holds a key
  • CREDENTIALS_KEYS set on the api and the worker
  • A provider API key to store
  • For a model server on your own network: the operator sets GATEWAY_ALLOW_INSECURE_BASE_URLS
provider Reaches base_url
openai api.openai.com Not allowed
anthropic api.anthropic.com Not allowed
openai_compatible Any server that speaks the OpenAI API: a vLLM or Ollama server, OpenRouter, Together, a company proxy Required

An openai or anthropic credential always uses the provider’s own host. A base URL there would only send the key somewhere else, so it is refused. A credential’s name is 1 to 100 characters and unique in the organization.

The API key is accepted when you create or rotate a credential and never returned again: not in the list, not in the audit log, not in the logs. It is trimmed of surrounding whitespace, must be visible ASCII without spaces and at most 512 characters.

It is stored encrypted with the master keys in CREDENTIALS_KEYS. The gateway decrypts it in memory for each call. Responses that describe a credential are marked Cache-Control: no-store.

CREDENTIALS_KEYS must be set, on the api and the worker. Without it, adding, rotating and checking a credential answer 409 NOT_CONFIGURED naming the setting. Format and generation are in Configuration.

An owner can add a credential from the dashboard or through the API with a personal access token that has the write scope. Build the request body with jq so that the provider key stays out of your shell history:

Terminal window
jq -n --arg key "$OPENAI_API_KEY" \
'{name: "openai-prod", provider: "openai", api_key: $key}' |
curl -sS -i -X POST "$SPANLIGHT_HOST/api/v1/orgs/$ORG/credentials" \
-H "Authorization: Bearer $SPANLIGHT_TOKEN" \
-H "Content-Type: application/json" \
-d @-

The answer is 201. The response is marked so that no browser or proxy keeps it, and the body describes the credential without its key:

Cache-Control: no-store
Pragma: no-cache
Content-Type: application/json
{
"id": "<credential id>",
"name": "openai-prod",
"provider": "openai",
"base_url": null,
"created_by": {
"id": "<user id>",
"email": "owner@example.com",
"name": "Ada",
"created_at": "2026-10-13T09:30:00Z",
"email_verified": true
},
"created_at": "2026-10-13T09:30:00Z",
"rotated_at": null,
"last_used_at": null,
"last_checked_at": null,
"last_error": null
}

A second credential with the same name answers 409 CREDENTIAL_NAME_TAKEN, and a server without CREDENTIALS_KEYS answers 409 NOT_CONFIGURED. Then check that the key works:

Terminal window
curl -sS -X POST "$SPANLIGHT_HOST/api/v1/orgs/$ORG/credentials/$CREDENTIAL/check" \
-H "Authorization: Bearer $SPANLIGHT_TOKEN"

A check always answers 200. A working key gives:

{ "status": "ok", "checked_at": "2026-10-13T09:30:00Z", "error": null }

A key the provider refuses gives "status": "error" and a short reason such as "error": "401 Unauthorized".

For openai_compatible, the base URL is the address the gateway appends paths to, for example https://openrouter.ai/api/v1 (so a call goes to …/chat/completions). A base URL must:

  • start with https://;
  • have a host, and no user name, password, query string or fragment;
  • be at most 2 048 characters, with no spaces or control characters;
  • use a host written in a standard way: an IP address must be in dotted-decimal form (127.1, 0177.0.0.1 and 2130706433 are refused, however a resolver might read them).

Trailing slashes are removed. Any failure is 422 on the field base_url.

A base URL can name any host, so the gateway protects the network it runs in. It resolves the host and refuses it when any address it resolves to is private or reserved: loopback, the RFC 1918 ranges (10.0.0.0/8, 172.16.0.0/12, 192.168.0.0/16), link-local (169.254.0.0/16, which includes cloud metadata services), carrier-grade NAT, unique-local and multicast IPv6, and the IPv4 addresses hidden inside IPv6 forms.

The check runs twice:

  1. When you save or rotate the credential. A host that resolves to a private address, or does not resolve, is a 422 on base_url, so you find out immediately.
  2. On every connection the gateway opens, with the address that is actually used. A name that was public when you saved it and later points to a private address (DNS rebinding) is refused at call time. The call fails with 502 UPSTREAM_BLOCKED and is neither retried nor sent to another target.

The gateway does not follow redirects, so a provider cannot send a call to an internal address either. A redirect answer is 502 UPSTREAM_UNREACHABLE.

Local model servers: GATEWAY_ALLOW_INSECURE_BASE_URLS

Section titled “Local model servers: GATEWAY_ALLOW_INSECURE_BASE_URLS”

To use a model server on your own machine or network, such as Ollama on http://localhost:11434/v1, the operator sets GATEWAY_ALLOW_INSECURE_BASE_URLS=true. It allows http:// and private, loopback and link-local addresses for every credential on that deployment. A host that does not resolve is still refused.

Leave it off on any deployment that is shared with people you do not trust: with it on, a credential can point the gateway at anything the server can reach. See Configuration.

Check calls the provider’s model list (GET /v1/models) with the stored key, without following redirects and with a 10 second timeout. It always answers 200 with either ok or error:

  • ok: the key works.
  • error: a short status line such as 401 Unauthorized, timeout, connection error, host not found, blocked address or key cannot be decrypted. The provider’s response body is never shown.

The result and its time are stored as last_checked_at and last_error, and shown in the list; both are empty for a credential never checked. last_used_at shows when the gateway last used the credential.

Rotate replaces the provider key of a credential, keeping its name, id and the routes that use it. The old key is gone from that moment, and the check result is cleared because it described the old key. A base URL is checked again against today’s rules when you rotate.

A credential that the current version of a route names cannot be deleted (409 CREDENTIAL_IN_USE). Remove it from the route first. An older route version may still name it; reverting to that version is then refused (422).

CREDENTIALS_KEYS holds one or more master keys, <key_id>:<base64 of 32 bytes>, comma-separated. The first entry seals new data, and every entry can open data sealed under its id. To rotate:

  1. Add the new key after the old one on the api and the worker, and restart both.

  2. Move the new key to the front.

  3. Re-seal the stored credentials, and the admin keys of billing sources, under it:

    Terminal window
    spanlight reseal-credentials

    It prints Re-sealed <n> provider credentials and <m> billing source keys., never a key, and exits with an error if any credential or billing source is still sealed under an older key or cannot be opened with any key you have. Only when it succeeds can you remove the older key.

With Compose, run it in the worker container: docker compose -f deploy/compose.yaml --env-file deploy/.env exec worker spanlight reseal-credentials. The full procedure, including how to verify each step, is in Rotate secrets.

Back up CREDENTIALS_KEYS outside the database backups. A lost key cannot be recovered; every credential sealed under it has to be entered again.

Press Check on the credential’s row. The status chip changes from “Never checked” to Working followed by “checked” and the time. Through the API, POST .../credentials/{id}/check answers 200 with "status": "ok".

  • The chip reads “Failing · 401 Unauthorized”. The provider refused the key. Rotate the credential with a working key, then Check again. The reason is the status line only; the provider’s body is never shown.
  • A status line such as “Failing · 403 Forbidden”, “404 Not Found”, “429 Too Many Requests” or a 5xx. This is the provider’s own answer to the model-list request. A 403 often means the key lacks access; a 404 often means a wrong base URL; a 429 or 5xx is on the provider’s side, so check again later. “Failing · key contains characters a provider cannot accept” means the stored key has non-ASCII characters: Rotate it with the correct key.
  • “Failing · host not found”, “connection error”, “timeout”, “blocked address” or “base URL not allowed”. The gateway could not reach the base URL, or it resolves to a private address, or it breaks today’s base URL rules. See Base URLs and private addresses.
  • “Failing · key cannot be decrypted”. The master key that sealed the credential is no longer in CREDENTIALS_KEYS. Add it back, or enter the provider key again with Rotate; see Rotate the master keys.
  • Saving shows “Provider credentials are encrypted with a server key that isn’t set. Ask your administrator to set CREDENTIALS_KEYS.” The server answered 409 NOT_CONFIGURED. Set CREDENTIALS_KEYS on the api and the worker.
  • 409 CREDENTIAL_NAME_TAKEN on the name field. The organization already has a credential with that name. Choose another.
  • A base URL is refused (422 on base_url). For example “Use an https:// URL.” It must be https:// and resolve to a public host; a local model server needs GATEWAY_ALLOW_INSECURE_BASE_URLS.
  • Delete answers 409 CREDENTIAL_IN_USE. The current version of a route names the credential. Remove it from the route first.