API Keys
API keys give machines access to your API without a user login. A partner, an integration or a script sends a key in the x-api-key header, and the gateway checks it before anything reaches your services.
Keys are not tied to users. Each key belongs to a named consumer, for example acme-corp: whoever you issued it to. Admins create, limit, rotate and revoke keys at runtime through the auth-service admin API. Changes take effect on the next request, with no redeploy.
This page covers the full auth template (the auth-service that init creates). Projects without it can still use keys by writing them to Redis themselves; see Managing keys without the auth service.
How it works
- Keys only reach endpoints marked
@PartnerApi(), under the/apiprefix. Every other endpoint has no/apiroute, so a key gets 404 there. See Gateway Routing. - The gateway validates the key on every request against Redis. Nothing but Redis is on the request path: the auth-service can be down or scaled to zero and keys keep working.
- Keys are 256-bit random strings, shown once when created, and stored only as a SHA-256 hash, in Postgres and in Redis. The gateway never forwards the key to your services; they receive the key id and the consumer name instead.
Marking endpoints for API keys
After adding @PartnerApi(), regenerate the gateway config (npx tsdevstack sync locally; infra:generate-kong, infra:build-kong, infra:deploy-kong in the cloud).
On a key request, the gateway sends two headers to your service: X-Api-Key-Id (the key id) and X-Api-Key-Consumer (the consumer name). AuthGuard turns them into req.authType === 'apiKey', req.service === 'partner' and req.apiKey = { id, consumer }; req.user is never set. Read them with @Partner() (the consumer name) or @ApiKey() ({ id, consumer }). Your service only trusts these headers on requests that came through the gateway; see Protected Routes.
Creating and managing keys
All key management goes through the auth-service admin API. Every endpoint needs a JWT from a user with the ADMIN system role (Authorization: Bearer <token>); how to become the first admin is in Managing Users. There is no UI: use curl, Swagger or the generated client.
Through the gateway the endpoints live under the auth-service prefix:
The generated client (@shared/auth-service-client) has them as adminListApiKeys, adminCreateApiKey, adminGetApiKey, adminUpdateApiKey, adminRevokeApiKey, adminRotateApiKey and adminGetApiKeyUsage.
The key object
Every endpoint except usage returns keys in this shape. It never contains the key itself, except key in a create or rotate response.
Create a key
The response is the key object plus key, the key itself (tsk_ followed by 43 characters). It is shown this one time only. Only its hash is stored, so a lost key cannot be recovered; rotate it instead. Hand it to the consumer over a secure channel.
A consumer can have several keys, for example one per environment or integration. Limits are per key, not per consumer.
Consumer names
- Kebab-case: lowercase letters, digits and single hyphens, starting with a letter (
acme-corp,partner2). - At most 64 characters.
- Not
internalorpartner, and not ending in-service: your services usereq.servicevalues like these for other kinds of callers.
Anything else gets 400 with the reason.
Update limits and expiry
PATCH changes name, any of the five limits, or expiresAt. A field you leave out stays as it is. null removes a limit (the gateway default applies again) or removes the expiry.
Changes apply at the gateway on the key's next request. Unlike creating, an update accepts an expiresAt in the past: the key expires immediately. An expired key can be brought back by moving expiresAt into the future. A revoked key cannot be updated (409).
Revoke
POST /auth/v1/admin/api-keys/:id/revoke stops the key on its next request: the gateway answers 401 api_key_revoked. Revoking cannot be undone; issue a new key instead. Revoking an already revoked key returns 200 and changes nothing. Revoked keys stay in the list as history.
Rotate
Rotation replaces a key without an outage:
- A new key is created with the same name, consumer, limits and expiry. The response has it as
newKey, includingkey(shown once). - The old key keeps working for the grace period: its expiry becomes the earlier of its current expiry and now plus
graceHours. The response has it aspreviousKey. graceHoursis 0 to 2160 (90 days), 168 (7 days) by default.0ends the old key immediately.- Both keys work during the grace, with separate counters.
- Revoked keys and expired keys cannot be rotated (409). For an expired key, extend its expiry first.
To end the grace early, revoke the old key.
Usage
GET /auth/v1/admin/api-keys/:id/usage returns the saved request totals, newest period first:
- Periods are ISO weeks (
2026-W40, weeks start Monday 00:00 UTC) and calendar months (2026-09, UTC). countis the number of requests the gateway admitted in that period, as of the last run of the usage job. Rejected requests are not counted.countis a JSON number, or a decimal string for values above 2^53 - 1.
Totals only move when the usage job runs: every 5 minutes in the cloud once it is scheduled, and only when you call it locally.
Error responses
Limits
Each key can limit five windows: minute, hour, day, week and month. All windows are fixed calendar windows in UTC: a minute starts at second 0, a day at 00:00 UTC, a week on Monday 00:00 UTC (ISO week), a month on the 1st at 00:00 UTC.
When a window is at its limit, the gateway answers 429 until that window ends:
rate_limit_exceededfor the minute, hour and day windows,quota_exceededfor the week and month windows.
Rejected requests do not count. Every window is checked and counted in one atomic step in Redis, so several gateway instances share the same counters and a burst cannot overshoot a limit.
Defaults from the global rate limiter
kong.user.yml has a global rate-limiting plugin that limits every caller (the generated file starts with minute: 100). On API key routes it works as the default for every key, counted per key instead of per IP:
- For each window, a key uses its own limit when it has one, otherwise the global value for that window. A key's own limit can be higher or lower than the global one, so you can sell a higher tier.
- The global
minute,hour,dayandmonthvalues become the defaults. The global limiter has noweek, so there is no default week limit.secondandyeardo not exist for keys:generate-kongwarns and ignores them on API key routes. - There is no way to make a key unlimited in a window that has a global default: give it a very high limit instead.
- Without an enabled global
rate-limitingplugin, keys without their own limits are only bounded by the per-IP ceiling. - Logged-in users and public routes keep the global limiter as before, per IP.
The defaults are copied into the gateway config when it is generated. After changing the global limiter, run npx tsdevstack sync locally, or infra:generate-kong, infra:build-kong and infra:deploy-kong in the cloud. Limits set on a key through the admin API need nothing of that: they apply on the next request.
Rate-limit headers
Every admitted request gets headers for each window that has a limit (the key's own or a default):
A 429 from a key limit carries the same headers plus Retry-After: seconds until the exceeded window ends (the longest one, when several are exceeded).
Expiry and trials
Any key can have an expiry. From the second expiresAt is reached, the gateway answers 401 api_key_expired; no job is involved. Keys without an expiry work until they are revoked.
There is no separate trial concept. A trial is a key with low limits and a near expiry. To upgrade a trial, update the limits and move or remove the expiry; the next request already runs on the new terms.
Gateway responses
What callers of your API get from the gateway on API key routes. Every error body is JSON with error and message:
Headers on these responses:
- Every 401 has
WWW-Authenticate: Key realm="tsdevstack". - Every 429 has
Retry-Afterand the rate-limit headers above. - Every 503 has
Retry-After: 5.
Two more responses come from elsewhere in the gateway:
- 404 (
no Route matched with those values): the path or method is not an@PartnerApi()operation. See Exact routes. - 429 with
{ "message": "API rate limit exceeded" }and noerrorfield: the per-IP ceiling. It carriesX-RateLimit-Limit-Minute,X-RateLimit-Remaining-Minute,RateLimit-*andRetry-After.
A revoked or expired key answers api_key_revoked or api_key_expired for about a day. After that its record is gone from Redis and the answer becomes invalid_api_key.
CORS headers on these responses come from your global cors plugin, like everywhere else. Browsers can only send x-api-key if it is in that plugin's allowed headers, as it is in the generated kong.user.yml.
The per-IP ceiling
API key routes have a second limit that has nothing to do with keys: at most 600 requests per minute from one client IP by default, whatever key they carry, valid or not. It runs before the key check, so it also counts guessing attempts with made-up keys. It is not a usage limit; give heavy users higher key limits and keep the ceiling as a safety net.
On API key routes the ceiling takes the place of the global limiter, which becomes the per-key default described above. Change it in .tsdevstack/config.json:
It must be a positive integer; anything else makes generate-kong and infra:generate-kong fail with an error. Like other gateway config, a change needs npx tsdevstack sync locally, or infra:generate-kong, infra:build-kong and infra:deploy-kong in the cloud.
If many legitimate clients share one IP address (a corporate proxy, a NAT), raise the ceiling.
Locally the gateway takes the client IP from a client-sent X-Forwarded-For header, so the ceiling can be bypassed on your machine. In the cloud the client IP comes from your load balancer.
Failure behavior
API key validation fails closed: when the gateway cannot check a key, the request gets 503 and never reaches your service. Logged-in users and public routes are not affected by any of this.
How the index is rebuilt
Postgres is the source of truth for keys; Redis holds a copy the gateway reads (the index). The auth-service writes every change to Redis first and then to Postgres, and undoes the Redis write if Postgres fails, so the two do not drift.
When Redis loses its data, a marker key (apikey:meta) disappears with it. A key the gateway cannot find while the marker is missing gets 503 index_unavailable instead of 401, so your callers retry instead of treating their key as invalid. The auth-service rebuilds the index when the marker is missing:
- when it starts (if Redis is reachable),
- every time its Redis connection comes back after an outage,
- every time the usage job runs.
The rebuild writes the record of every active key that has no record yet, never overwriting one, so it cannot bring back a revoked key. It adds the week and month totals saved by the usage job to the counters, so quotas survive the loss up to the last job run. Minute, hour and day counters start from zero. Only one auth-service instance rebuilds at a time. The marker is written last.
To reset the index, flush Redis completely, then call the usage job (locally: curl -X POST http://localhost:3001/auth/jobs/sync-api-key-usage) or restart the auth-service. Flushing a running Redis does not reconnect the auth-service, so the rebuild waits for one of these.
Never delete only apikey:meta while the counters are still there. The rebuild would add the saved week and month totals on top of the live counters and count that usage twice.
The usage job
The auth-service has a job endpoint, POST /auth/jobs/sync-api-key-usage, that:
- rebuilds the key index if Redis lost it,
- copies the current and previous week and month totals of every key from Redis to Postgres (a saved total never goes down),
- copies each key's last-used time (
lastUsedAt).
When the index is still missing because another instance is rebuilding it, the job copies nothing and returns success: false; the next run catches up.
The job is not scheduled for you. Add it to scheduledJobs of every cloud environment in .tsdevstack/infrastructure.json:
The endpoint includes the auth-service prefix (/auth unless you changed it). infra:generate prints a warning with the exact entry for your project when an environment does not have it. Deploy it with npx tsdevstack infra:deploy-schedulers --env <env> (a full infra:deploy includes schedulers).
Without the job, usage totals and lastUsedAt never reach Postgres, week and month quotas restart from zero after a Redis loss, and a lost index is only rebuilt when the auth-service restarts or reconnects to Redis. See Scheduled Jobs for how jobs work.
Local development
Everything runs locally: the gateway image includes the key plugin, and Redis is part of the generated docker-compose.yml.
- After upgrading the CLI, run
npx tsdevstack syncso the gateway image and config are regenerated. - Become admin and get an access token: Managing Users.
- Create a key with the curl call from Create a key and copy
keyfrom the response. - Call an
@PartnerApi()endpoint through the gateway:
Scheduled jobs do not run locally. To see usage totals or lastUsedAt, call the job by hand:
Use the auth-service port from .tsdevstack/config.json if you changed it. Locally SchedulerGuard lets the call through without credentials.
User-owned keys
Keys belong to consumers, and only admins manage them. If your product needs self-service keys (each user creates keys for their own account), build that on top: add your own endpoints in the auth-service that reuse its key service, check that the caller owns the account, and record which user or account a key belongs to in your own table. The gateway only cares about the Redis record described below.
Managing keys without the auth service
Projects without the auth template ("template": null) get the same gateway behavior: @PartnerApi() routes still check keys against Redis. Nothing writes keys for you, so generate-kong prints a note when your project has @PartnerApi() routes and no auth template. Your own tooling writes the key records described here. This section is a public, versioned contract.
Requirements
- Write to the same Redis the gateway uses (the
REDIS_HOST,REDIS_PORTandREDIS_PASSWORDsecrets), database 0. - Redis must be one endpoint: standalone, a primary endpoint, or a cluster endpoint that proxies commands. The gateway does not follow cluster redirects. Every generated environment already works this way.
- Keep Redis on
noeviction(the framework configures it everywhere). - Your tooling writes the records and the marker; the gateway maintains the counters.
Key names
<h> is the SHA-256 of the key exactly as clients send it in x-api-key (UTF-8), as 64 lowercase hex characters. The braces are part of the name: they are a Redis Cluster hash tag that keeps all entries of one key in one slot.
Other names under apikey: are reserved (the auth-service uses apikey:rebuild-lock and apikey:{<h>}:seeded:... for its rebuild). Don't write anything else under that prefix.
The record
- Leave out absent fields.
nullis invalid. - Times are integer epoch seconds, UTC.
- The auth-service writes the fields in the order shown (
v,id,consumer,status,limitsfrom minute to month,expiresAt); the gateway does not depend on the order. Unknown extra fields are ignored. - A record that breaks these rules gets 503
gateway_unavailableand an error line in the gateway log.
Expiry (TTL) of the record entry:
Revoke by writing the full record with "status":"revoked" and a one-day TTL, not by deleting it: during that day callers get api_key_revoked instead of invalid_api_key. Every change is a full record write; there is no field-level update.
What the gateway does with the counters, so you can read them: minute, hour and day counters exist only while a limit applies to that window and expire at the end of their window. Week and month counters count every admitted request, with or without a limit, and expire one day after their window ends, so you can still read the final total of a finished period. lu is written at most once a minute and expires after 30 days without use.
The marker
apikey:meta tells the gateway that the index is complete. While it exists, a key without a record gets 401 invalid_api_key. While it is missing, the gateway assumes Redis lost its data and answers 503 index_unavailable.
- Write it after you have written every record, and write it again after Redis loses its data. The auth-service writes
{"v":1,"rebuiltAt":<epoch seconds>}; only its existence counts. - Never give it a TTL, and never delete it on its own.
Example
With redis-cli, for a client that sends x-api-key: tsk_example:
In a Node.js project, @tsdevstack/nest-common exports the contract as code, so your tooling cannot drift from it:
The package also exports validateApiKeyRecord, decodeApiKeyRecord, buildApiKeyCounterKey, buildApiKeyLastUsedKey, the window helpers (getApiKeyWindowStart, getApiKeyWindowEnd, getApiKeyWindowId, getApiKeyCounterExpireAt) and the API_KEY_* constants.
Compatibility
The record format is versioned by v. Newer gateway versions keep reading every earlier record version, and a breaking change to the format gets a new v. Records your tooling wrote for version 1 keep working after a CLI upgrade. A record with a v the gateway does not know (written by newer tooling than the gateway) gets 503 gateway_unavailable; upgrade the CLI and rebuild the gateway.
Migrating from static partner keys
Earlier versions configured partner keys as consumers with keyauth_credentials in kong.user.yml, with the key values in .secrets.user.json. Those keys no longer work: partner routes only check keys in Redis. generate-kong and infra:generate-kong warn while kong.user.yml still has such consumers.
To move your partners over without an outage:
- Upgrade and add the API key parts of the auth-service (see the release notes).
- Deploy the auth-service, then create a key per partner through the admin API and hand the keys over. The old keys keep working until the gateway is redeployed.
- Regenerate and redeploy the gateway (
npx tsdevstack synclocally;infra:generate-kong, commitinfrastructure/kong/<env>/kong.yml,infra:build-kong,infra:deploy-kongin the cloud). From here only the new keys work. - Remove the consumers from
kong.user.ymland the old key values from.secrets.user.jsonand each cloud environment (npx tsdevstack cloud-secrets:remove <KEY> --env <env>).
Partner keys are not secrets of your project anymore. Nothing about them goes into .secrets.user.json or the cloud secret manager.