A REST microservice for issuing, listing, revoking, and validating API keys. Built with Hono and PostgreSQL.
- Generates
ak_-prefixed API keys and returns the plaintext exactly once, at creation - Stores only an Argon2id hash of each key (with optional pepper), never the plaintext
- Fast validation via a
key_hintpre-filter so/validateavoids scanning the whole table - Per-user key listing and revocation (revoked keys are kept, not deleted)
- Optional key expiry
- Pluggable auth: OIDC/JWT in production, a trusted header in dev
- Node.js 24+
- PostgreSQL
npm install
cp .env.example .env # then edit as needed
npm run db:migrate # apply the database schema
npm run dev # start the dev server with hot reload on :3000Set these in .env (see .env.example):
| Variable | Required | Description |
|---|---|---|
DATABASE_URL |
Yes | PostgreSQL connection string |
PORT |
No | HTTP port (default 3000) |
OIDC_JWKS_URI |
No | JWKS endpoint for verifying Bearer JWTs. If unset, the API falls back to trusting the X-User-ID header (dev only) |
OIDC_ISSUER |
No | Expected iss claim, checked when OIDC_JWKS_URI is set |
OIDC_USER_CLAIM |
No | JWT claim used as the user ID (default sub) |
KEY_PEPPER |
No | Secret string appended to key plaintext before hashing |
ARGON2_MEMORY_COST |
No | Argon2id memory cost in KiB (default 65536) |
ARGON2_TIME_COST |
No | Argon2id time cost (default 3) |
ARGON2_PARALLELISM |
No | Argon2id parallelism (default 1) |
/keys/* routes require authentication:
- With
OIDC_JWKS_URIset: requests must carryAuthorization: Bearer <jwt>. The token is verified against the remote JWKS (andOIDC_ISSUERif set), and the configured claim (OIDC_USER_CLAIM, defaultsub) becomes the user ID. - Without it (dev mode): requests must carry an
X-User-IDheader, trusted as-is.
/validate is unauthenticated — callers present the raw API key in the request body.
Downstream services integrate with this API by validating keys on every request, without authenticating themselves.
flowchart LR
Caller[Caller] -- "request with ak_ key" --> Service[Downstream service]
Service -- "POST /validate { api_key }" --> API[api-key-manager-api]
API -- "valid, user_id, expires_at" --> Service
Service -- "allow / deny" --> Caller
A downstream service receives a request carrying an ak_... key and calls /validate — unauthenticated, since the key itself is the credential — to check it's valid before serving the request. This is the hot path and is designed to be stateless and horizontally scalable.
Returns the application name and version (the git tag the image was built from, or dev).
{ "application_name": "api-key-manager", "version": "v1.0.0" }Create a new key for the authenticated user. Returns the plaintext key — the only time it is ever exposed.
Request body (optional fields):
{ "name": "CI pipeline", "expires_at": "2027-01-01T00:00:00Z" }Response 201:
{ "id": "...", "name": "CI pipeline", "expiresAt": "2027-01-01T00:00:00.000Z", "api_key": "ak_..." }List keys belonging to the authenticated user (never includes plaintext or hashes). Supports pagination via ?limit= (default 20, max 100) and ?offset=, and filtering via ?revoked=true or ?revoked=false. Omit revoked to return all keys regardless of status.
Response 200:
{ "items": [ { "id": "...", "name": "...", "createdAt": "...", "lastUsedAt": "...", "revoked": false, "expiresAt": null } ], "total": 1 }Revoke a key owned by the authenticated user. Sets revoked=true; the row is kept. Returns 204, or 404 if the key doesn't exist or isn't owned by the caller.
Verify an API key. Unauthenticated — this is the hot path used by other services to check keys on every request.
Request body:
{ "api_key": "ak_..." }Response 200:
{ "valid": true, "user_id": "...", "expires_at": null }Returns { "valid": false } if the key is malformed, unknown, revoked, or expired. On success, last_used_at is updated before the response is sent.
Single table, api_keys, managed with Drizzle ORM:
| Column | Type | Notes |
|---|---|---|
id |
uuid | Primary key |
user_id |
text | Owner |
name |
text | Optional label |
hash |
text | Argon2id hash of the key (never the plaintext) |
key_hint |
text | First 8 chars of the plaintext; pre-filters /validate lookups |
created_at |
timestamp | |
last_used_at |
timestamp | Updated on successful validation |
expires_at |
timestamp | Nullable |
revoked |
boolean | Set on revocation; rows are never deleted |
npm run db:generate # generate a migration after editing src/db/schema.ts
npm run db:migrate # apply pending migrationsnpm run dev # dev server with hot reload (tsx watch)
npm run build # compile TypeScript to dist/
npm run start # run the compiled outputA Dockerfile and Kubernetes manifests (kubernetes_manifest.yml) are included. The container runs node dist/src/index.js after npm run build; configuration is passed entirely via environment variables (see Configuration).