Skip to content

Repository files navigation

keywarden

Auth and API-key custody engine — issuance, validation, and revocation for multi-tenant services. Built to reason clearly about token lifecycle, replay protection, and safe key revocation under concurrent access, not to be a CRUD-auth starter kit.

Stack: NestJS · PostgreSQL · Prisma 7 (driver adapter) · Redis · BullMQ · JWT · bcrypt · Docker Compose · Nginx


Why this exists

Most "auth boilerplate" repos stop at register/login. The interesting engineering problems in an API-key system show up after that: how you rotate refresh tokens without opening a replay window, how you look up a hashed API key without scanning every row in the table, how you throttle and audit usage per key without coupling that logic to the request path, and how your schema behaves when a user or key is deleted but their usage history needs to survive. This repo is where I work through those problems directly, without leaning on an off-the-shelf auth-as-a-service provider — the point is to own the decisions, not to configure someone else's.


Architecture & tradeoffs

1. Refresh token rotation with replay protection

Access tokens are short-lived JWTs. Refresh tokens are opaque, bcrypt-hashed before storage, and delivered via an httpOnly cookie. On every refresh, the presented token is matched against the requesting user's active sessions (candidate rows narrowed by userId, matched via bcrypt.compare since hashes aren't directly queryable), the matched row is deleted, and a new pair is issued. A refresh token that's already been rotated out simply won't match any active row on a second use — closing the window where a leaked refresh token can be reused indefinitely. The design also supports multiple simultaneous sessions per user (one row per device/login), not just a single active session.

2. API key lookup: indexed keyId + hashed secret, not a full-table scan

API keys are generated as ak_<keyId>_<secret>. keyId is a short, random, unencrypted identifier stored in its own indexed column — it's not sensitive, it's a lookup handle. secret is bcrypt-hashed before storage and never persisted in plaintext. On an incoming request, the guard does an O(1) indexed lookup by keyId, then a single bcrypt.compare against that one row's hash. This avoids the alternative of looping and hashing against every active key in the table on every request — which doesn't scale past a handful of keys.

3. Schema design: SetNull on UsageLog foreign keys

UsageLog rows reference the User and ApiKey that generated them. On deletion, both FKs are SetNull rather than Cascade — usage history is treated as an audit trail that should outlive the key or user it was generated by. This is a deliberate tradeoff: usage queries need to handle a nullable reference, but deleting a key or account can't silently erase billing/usage evidence.

4. Revoke endpoint: soft-delete over hard-delete

DELETE /api-key/:id soft-deletes (isActive: false, revokedAt timestamp) rather than removing the row. Given the SetNull design above, a hard delete would have been schema-safe too, but soft-delete preserves the ability to show "this key existed and was revoked on X" in an audit UI, and keeps revocation non-destructive. The cost is that every active-key query needs an isActive: true filter — accepted as the right tradeoff for auditability. (Refresh token rotation, by contrast, uses hard-delete — a rotated-out session has no audit value worth keeping, unlike a revoked API key.)

5. Rate limiting: Redis fixed-window, scoped per key

Each API key is throttled independently via a Redis INCR/EXPIRE fixed-window counter, keyed by apiKeyId and the current time bucket (floor(now / windowSeconds)). The limit and window are both env-configurable, not hardcoded. Fixed-window has a known imprecision at window boundaries (a client can burst up to ~2x the limit across a boundary) — accepted deliberately over the added complexity of a sliding-window or token-bucket implementation, since the goal here is demonstrating per-resource throttling correctly, not shipping a fintech-grade limiter.

6. Usage logging: async via BullMQ, with a deliberate scope boundary

Successful and handler-level-failed requests are logged asynchronously — the request path enqueues a job and returns immediately; a separate WorkerHost processor writes to UsageLog, with 3 attempts and exponential backoff on failure. Rate-limit rejections (429) are also logged here, since the key is already authenticated at that point and the rejection is real, attributable usage. Authentication failures (missing or invalid key) are deliberately not written to UsageLog — they're not identifiable usage by a real key or user, and logging them there would mix billing/usage data with abuse-probing noise. Those are logged via Nest's Logger instead, as a dev-facing signal, with a TODO for real alerting infrastructure (Sentry, on-call paging) once that's in scope.

7. Prisma 7 driver adapter migration

PrismaService uses the Prisma 7 driver adapter pattern (@prisma/adapter-pg) rather than the legacy bundled engine binary, with the connection string resolved through ConfigService rather than read directly from process.env. This decouples the query engine from a platform-specific native binary — relevant both for Docker image size and for avoiding engine mismatches between local dev and the containerized build.

8. Environment separation

.env (local, localhost networking) and .env.docker (containerized, Docker service-name networking) are kept fully separate and loaded via Compose's env_file: rather than variable interpolation — the two contexts resolve hostnames differently (postgres/redis only resolve inside the Docker network), and mixing the two led to real connection failures during development. Joi-based validation is wired into ConfigModule so a missing or malformed env var fails at application boot, not at first request.

(Benchmarks — validation latency, throughput under load — are on the roadmap once the module has integration test coverage; not included here to avoid citing numbers that haven't been measured yet.)


What's implemented

  • Auth module — register, login (access + refresh JWT pair), bcrypt-hashed refresh tokens, httpOnly cookies, rotation with replay protection across multiple concurrent sessions, logout with session revocation
  • Global JwtAuthGuard with a @Public() opt-out decorator (Reflector.createDecorator)
  • API key module — create (ak_<keyId>_<secret> format, bcrypt-hashed secret, masked keyPreview for display), list (user-scoped, active-only, newest-first, Prisma select-level field filtering), revoke (soft-delete, ownership-checked, idempotent)
  • ApiKeyGuard — indexed keyId lookup + bcrypt secret verification, no full-table scan
  • RateLimitGuard — Redis fixed-window rate limiting, per-key, env-configurable limit and window
  • Usage logging — BullMQ producer/consumer, async writes to UsageLog with retry + backoff, rate-limit rejections included, auth failures excluded by design (see tradeoff #6)
  • Prisma schemaUser, RefreshToken, ApiKey, UsageLog with hashed fields, indexed FKs, and SetNull audit-preserving relations
  • Full Docker Compose dev environment — Postgres, Redis, Nginx, healthcheck-gated startup, multi-stage Dockerfile with Prisma client generation at a custom output path

Roadmap

  • Integration/E2E test coverage (Jest + supertest)
  • Real alerting for exhausted-retry usage-log jobs and repeated auth failures
  • Public API documentation (OpenAPI/Swagger)

Running locally

cp .env.example .env
docker compose --env-file .env.docker up --build

Postgres, Redis, and Nginx come up behind healthchecks before the API container starts. Prisma migrations run on boot.

For local development against the containerized Postgres/Redis without rebuilding the app image on every change, point .env's DATABASE_URL/REDIS_HOST at localhost and run the app with npm run start:dev.


Testing

Jest for unit tests, supertest for E2E. Endpoint testing during development via Postman; DB state verified via Prisma Studio / raw SQL where a UI would hide the actual query behavior.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages