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
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.
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.
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.
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.
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.)
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.
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.
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.
.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.)
- 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
JwtAuthGuardwith a@Public()opt-out decorator (Reflector.createDecorator) - API key module — create (
ak_<keyId>_<secret>format, bcrypt-hashed secret, maskedkeyPreviewfor display), list (user-scoped, active-only, newest-first, Prismaselect-level field filtering), revoke (soft-delete, ownership-checked, idempotent) ApiKeyGuard— indexedkeyIdlookup + bcrypt secret verification, no full-table scanRateLimitGuard— Redis fixed-window rate limiting, per-key, env-configurable limit and window- Usage logging — BullMQ producer/consumer, async writes to
UsageLogwith retry + backoff, rate-limit rejections included, auth failures excluded by design (see tradeoff #6) - Prisma schema —
User,RefreshToken,ApiKey,UsageLogwith hashed fields, indexed FKs, andSetNullaudit-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
- Integration/E2E test coverage (Jest + supertest)
- Real alerting for exhausted-retry usage-log jobs and repeated auth failures
- Public API documentation (OpenAPI/Swagger)
cp .env.example .env
docker compose --env-file .env.docker up --buildPostgres, 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.
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.