Auth Microservice Docs

GitHub

How I Built a Production-Grade Auth Service From Scratch

A complete authentication microservice with JWT, MFA, OAuth, role-based access, and monitoring. Explained two ways.

30+
Files
25
API Endpoints
136
Tests Passing
14
Security Layers

What Is This?

Think about every app you use. Instagram, Gmail, Spotify, your banking app. Before you can scroll your feed, read an email, or play a song, you have to log in.

That login screen looks simple. Just an email and a password. But behind that little form, there's an entire system deciding if you're really you, keeping hackers out, remembering you so you don't have to log in every 5 minutes, and locking things down when something suspicious happens.

Someone had to build that system. This is what I built.

Think of it like a bouncer at a nightclub. He checks your ID at the door (authentication), checks if you're on the VIP list (authorization), gives you a wristband so you don't have to show your ID at every bar inside (tokens), and writes your name in the logbook so there's a record of everyone who came and went (audit trail).

Except this bouncer also:

  • Lets you skip the line if you have a Google or GitHub badge, like "Sign in with Google"
  • Sends a text to your phone to double-check it's really you. Even if someone stole your password, they still need your phone
  • Emails you a secret link if you forget your password
  • Locks the door after 5 wrong guesses so nobody can brute-force their way in
  • Keeps a detailed security log of every login, every failure, every suspicious event
What this project handles:
  • Sign up and Log in with email/password or Google/GitHub OAuth
  • Two-factor authentication (MFA) with 6-digit codes from an authenticator app + backup codes
  • Password reset via secure email link, auto-logs out all devices
  • Session management to see which devices you're logged in on and revoke any of them
  • Admin dashboard to manage users, view audit logs, and 7-day stats
  • Auto-monitoring with Prometheus metrics and Grafana dashboards out of the box

A production-grade authentication & authorization microservice built on Node.js/Express with TypeScript strict mode. It implements industry-standard security patterns including JWT RS256 asymmetric signing, Argon2id password hashing, TOTP-based MFA, refresh token rotation with family-based reuse detection, Redis-backed sliding window rate limiting, and a comprehensive audit trail.

Architecture Principles
  • Dependency Injection: Services accept Prisma/Redis clients via constructor
  • Modular Feature Organization: auth, mfa, oauth, user, session, admin modules
  • Defense in Depth: 14 security layers from Helmet headers to audit logging
  • Zero Trust Tokens: Asymmetric RS256, token blacklist, rotation, family tracking
  • Operational Readiness: Health checks, Prometheus metrics, Grafana dashboards, structured logging

The Big Picture

Here's how the whole system works at a high level:

User's Browser Our Server | | | 1. "I want to sign up" | | -------- email + password ---------> | | | Saves user to database | | Creates a "pass" (token) | <-------- here's your pass ---------- | | | | 2. "Show me my profile" | | -------- shows the pass -----------> | | | Checks: is the pass valid? | | Checks: is it blacklisted? | <-------- here's your data ---------- | | | | 3. "Log me out" | | -------- shows the pass -----------> | | | Invalidates the pass | <-------- done! -------------------- |

The "pass" is called a JWT token. It's a long string of characters that proves who you are. You get a short-lived one (15 minutes) and a long-lived one (7 days) that can get you a new short one.

The system uses a dual-token architecture with RS256 asymmetric JWTs:

Client API Gateway Services Data Stores | | | | | POST /auth/register | | | |------------------------->| Helmet + CORS + Comp | | | | RequestLogger (reqId) | | | | Metrics (prom-client) | | | | RateLimiter (Redis ZSET)| | | | validateRequest (Zod) | | | |------------------------->| | | | | argon2id(password) | | | |------------------------->| PostgreSQL | | | jwt.sign(RS256, privKey) | | | |------------------------->| Redis (token hash) | | | | |<------- 201 { user, accessToken (15m), refreshToken (7d) } -------------------| | | | | | GET /users/me | | | | Authorization: Bearer | | | |------------------------->| authenticate middleware | | | | jwt.verify(pubKey) | | | | redis.get(bl:{token}) | | | | req.userId = decoded | | | |------------------------->| | |<------- 200 { user profile } ---------------------------------------------|

Tech Stack

Here are the main tools I used and why:

ToolWhat It DoesWhy I Chose It
Node.js + ExpressRuns the serverFast, huge ecosystem, everyone knows it
TypeScriptAdds type safety to JavaScriptCatches bugs before they happen
PostgreSQLStores users, tokens, logsRock-solid relational database
RedisFast in-memory cacheRate limiting, token blacklist, session tracking
PrismaTalks to the database for usAuto-generates TypeScript types from schema
DockerPackages everything into containersOne command to run the whole thing
LayerTechnologyRationale
RuntimeNode.js 20 + TypeScript 5.6 (strict)Strict mode with noImplicitAny, strictNullChecks, noUnusedLocals
FrameworkExpress 4.21Minimal overhead, composable middleware, massive ecosystem
ORMPrisma 5.22Type-safe query builder, automatic migration SQL, introspection
Cacheioredis 5.4 → Redis 7Pipelining, Lua scripting, sorted sets for sliding window
Authjsonwebtoken (RS256) + Argon2idAsymmetric JWTs for microservice verification, memory-hard hashing
MFAotplib (RFC 6238) + qrcodeTOTP standard, compatible with Google Authenticator / Authy
OAuthPassport.js (Google + GitHub strategies)Proven library, handles PKCE + state parameter automatically
ValidationZod 3.23Runtime type validation, auto-strips unknown fields, composable
LoggingPino 9.5JSON structured, 5x faster than winston, automatic redaction
Metricsprom-client 15.1Prometheus-native, histograms for latency, counters for auth events
Security HeadersHelmet 8CSP, HSTS (1yr), X-Frame-Options DENY, nosniff, referrer-policy
EmailNodemailer 8Ethereal auto-accounts in dev, configurable SMTP in prod
TestingJest 29 + Supertest 7 + ts-jestUnit + integration + E2E, 80% coverage thresholds
ContainerDocker multi-stage (Alpine) + docker-compose263MB production image, dumb-init for PID 1, non-root user

Project Structure

The code is organized like a building where each floor has a specific purpose:

src/ config/ Settings: database connection, Redis connection, environment variables middleware/ Guards: checks every request before it reaches your code modules/ Features: each feature gets its own folder auth/ Sign up, log in, log out, password reset mfa/ Two-factor authentication (authenticator app codes) oauth/ Google & GitHub "Login with..." buttons user/ View/edit your own profile session/ See which devices you're logged in on admin/ Admin-only: manage users, view audit logs shared/ Reusable code: error classes, crypto helpers, logger

Each module follows the same pattern: routes (URLs) → controller (handles the request) → service (business logic) → database.

Follows a modular monolith pattern with feature-based organization. Each module has its own controller, service, routes, validation schemas, and tests.

src/ server.ts Bootstrap: createApp(), listen(), graceful shutdown (SIGTERM/SIGINT), TokenCleanupService app.ts Express factory: middleware stack, DI wiring, route mounting, Swagger setup config/ env.ts Zod schema validation for all env vars, exported as typed singleton database.ts PrismaClient singleton with query/error logging (dev only) redis.ts ioredis client with exponential backoff retry strategy middleware/ authenticate.ts JWT Bearer extraction → RS256 verify → Redis blacklist check → req.userId authorize.ts Role-based guard: authorize('ADMIN') returns 403 if role mismatch rateLimiter.ts Redis ZSET sliding window, path normalization, X-RateLimit-* headers validateRequest.ts Zod schema.safeParse on body/params/query, strips unknowns errorHandler.ts Catches AppError/ZodError/PrismaError, masks non-operational in prod requestLogger.ts Assigns X-Request-ID, logs start/end with duration, redacts passwords metricsCollector.tsPrometheus http_requests_total counter, http_request_duration histogram helmet.ts CSP default-src 'self', HSTS 1yr, frameguard DENY, nosniff cors.ts CORS_ORIGINS whitelist, credentials: true, exposed rate-limit headers modules/auth/ auth.routes.ts 7 endpoints, Swagger JSDoc annotations, authRateLimiter on sensitive routes auth.controller.ts Thin layer: extract req fields, call service, format response envelope auth.service.ts Core logic: register, login (lockout+MFA), refresh (rotation), logout (blacklist) auth.validation.ts Zod schemas: registerSchema, loginSchema, forgotPasswordSchema, resetPasswordSchema auth.test.ts Unit tests: argon2 hashing, JWT RS256, crypto utils, Zod schemas shared/ errors/ AppError hierarchy: Authentication, Authorization, Validation, NotFound, Conflict, RateLimit, Internal utils/ jwt.ts Lazy-loaded RSA keys, generate/verify access+refresh tokens, jwtid for uniqueness password.ts Argon2id: 64MB memory, 3 iterations, 4 parallelism (OWASP 2023) crypto.ts AES-256-GCM encrypt/decrypt, generateSecureToken (32 bytes), hashToken (SHA-256) services/ email.service.ts Nodemailer: auto Ethereal in dev, SMTP env config in prod, HTML templates tokenCleanup.service.ts setInterval + unref(), deletes expired/revoked RefreshTokens hourly

How Authentication Works

Authentication is like a passport system. Here's the journey a user takes:

1
Sign Up

User provides email + password. We hash the password (scramble it so no one can read it), save the user, and send a verification email.

2
Verify Email

User clicks the link in the email. We mark their account as verified.

3
Log In

User provides email + password. We check the password against the scrambled version. If correct, we give them two tokens: a short pass (15 min) and a long pass (7 days).

4
Use the App

Every request sends the short pass in the header. We verify it's real and not expired.

5
Refresh

When the short pass expires, the long pass gets a new short pass. The old long pass is destroyed.

6
Log Out

The short pass gets blacklisted so no one can use it, even if they stole it.

Complete Authentication Lifecycle

Registration Client POST /auth/register {email, password, name} → Zod validates: email (normalized), password (≥8, upper+digit+special) → Check uniqueness: prisma.user.findUnique({email}) → argon2id.hash(password, {memory:64MB, time:3, parallelism:4})crypto.randomBytes(32).toString('hex') → emailVerifyToken → SHA-256(emailVerifyToken) → stored in User.emailVerifyToken → prisma.user.create({passwordHash, emailVerifyToken: hash}) → emailService.sendVerificationEmail(email, rawToken) (async, non-blocking) → generateTokenPair(userId, email, role) → jwt.sign({type:'access', jwtid:uuid}, privateKey, {alg:RS256, exp:15m}) → jwt.sign({type:'refresh', family:uuid, jwtid:uuid}, privateKey, {alg:RS256, exp:7d}) → prisma.refreshToken.create({tokenHash: SHA-256(refresh), family, expiresAt}) → auditLog.create({action:'REGISTER'}) → 201 {user, accessToken, refreshToken} Login (with MFA flow) Client POST /auth/login {email, password} → Find user by email (or throw generic "Invalid email or password") → Check user.deletedAt === null (soft-delete aware) → Check user.lockedUntil < now() (account lockout) → argon2.verify(storedHash, providedPassword) → On failure: → redis.incr(failed_login:{userId}) with TTL 900s → If attempts ≥ 5: prisma.user.update({lockedUntil: now+15min}) → Throw "Invalid email or password" (no email enumeration) → On success: → redis.del(failed_login:{userId}) // reset counter → If user.mfaEnabled: → jwt.sign({type:'mfa_challenge'}, privKey, {exp:15m}) → 200 {mfaRequired: true, mfaToken} → Else: → generateTokenPair(...) → 200 {mfaRequired: false, user, accessToken, refreshToken} Token Refresh (Rotation + Reuse Detection) Client POST /auth/refresh {refreshToken} → tokenHash = SHA-256(refreshToken) → storedToken = prisma.refreshToken.findUnique({tokenHash}) → If storedToken.revokedAt !== null:REUSE DETECTED: possible token theft! → prisma.refreshToken.updateMany({family, revokedAt: null} → {revokedAt: now}) → Throw "Token reuse detected. All sessions revoked" → If storedToken.expiresAt < now: throw "expired" → jwt.verify(refreshToken, publicKey) → validate signature → Generate new pair in same family → Mark old token: revokedAt=now, replacedBy=newTokenId → 200 {accessToken, refreshToken}

Database Design

The database has 5 tables. Think of each table as a spreadsheet:

Users
Stores everyone who signs up. Has their email, scrambled password, whether they've verified their email, and if they've turned on two-factor auth.
Refresh Tokens
Tracks every "long pass" ever issued. When you refresh, the old one gets marked as "used" and a new one appears. If someone tries to reuse an old one, we know it was stolen and revoke everything.
OAuth Accounts
Links Google/GitHub accounts to users. One user can have both Google and GitHub linked.
Audit Logs
A diary of every security event: who logged in, from where, when, and whether it worked.

Entity Relationship

User (1) ──────┬─── has many ───> RefreshToken (*) | | tokenHash (unique, SHA-256) | | family (UUID, for rotation groups) | | expiresAt, revokedAt, replacedBy | | | ├─── has many ───> OAuthAccount (*) | | provider + providerId (unique compound) | | | ├─── has many ───> BackupCode (*) | | codeHash (SHA-256), usedAt (null = available) | | | └─── has many ───> AuditLog (*) | action, ipAddress, userAgent, metadata (JSON) | ├── id: cuid() ├── Indexes: email, deletedAt ├── email: unique ├── passwordHash: nullable (for OAuth-only users) ├── role: USER | ADMIN ├── mfaSecret: AES-256-GCM encrypted ├── emailVerifyToken: SHA-256├── lockedUntil: DateTime? (account lockout) └── passwordResetToken + Expiry (1hr TTL)

Key Design Decisions

  • Token hashing: Never store raw tokens. All tokens (refresh, email verify, password reset) stored as SHA-256 hashes. If the DB is breached, tokens are useless.
  • Refresh token families: Each login session creates a family UUID. All rotated tokens share it. If a revoked token is replayed, the entire family gets nuked.
  • Soft deletes: Users have deletedAt instead of hard deletes. Preserves audit trail integrity.
  • MFA secret encryption: TOTP secrets encrypted at rest with AES-256-GCM using a dedicated MFA_ENCRYPTION_KEY. Format: iv:authTag:ciphertext (all hex).
  • Backup codes: 8 codes generated per MFA enrollment, each SHA-256 hashed. One-time use (usedAt timestamp marks consumption).

Security Deep Dive

Security is built in at every level. Here are the protections:

ProtectionWhat It Means
Password HashingPasswords are scrambled using Argon2id. Even if hackers steal the database, they can't recover passwords.
Token RotationEvery time you refresh your login, you get a brand new token and the old one is destroyed. If a hacker steals the old one, the system detects it and locks down all your sessions.
Two-Factor AuthEven if someone knows your password, they need a 6-digit code from your phone to get in.
Rate LimitingLimits how fast someone can try passwords. After 5 wrong guesses, the account locks for 15 minutes.
Email Enumeration PreventionThe login page never tells you "this email doesn't exist" so hackers can't probe for valid emails.
Security HeadersBrowser protections that prevent clickjacking, code injection, and other attacks.
Audit LogsEvery security event is recorded: logins, failures, password changes, role changes.

1. Password Security: Argon2id

// OWASP 2023 recommended parameters
const ARGON2_OPTIONS = {
  type: argon2.argon2id,     // Hybrid: resists GPU + side-channel attacks
  memoryCost: 65536,        // 64 MiB per hash
  timeCost: 3,              // 3 iterations
  parallelism: 4,           // 4 threads
};

Each hash includes a random salt. The output is self-describing: $argon2id$v=19$m=65536,t=3,p=4$SALT$HASH. Verification is constant-time to prevent timing attacks.

2. JWT Architecture: RS256

// Asymmetric signing: private key signs, public key verifies
// This means microservices only need the PUBLIC key to verify tokens
const signOptions: SignOptions = {
  algorithm: 'RS256',
  expiresIn: '15m',        // Short-lived access tokens
  subject: payload.userId,
  jwtid: crypto.randomUUID(),  // Unique per token (prevents same-second collisions)
};

// Access token payload
{ userId, email, role, type: 'access', iat, exp, sub, jti }

// Refresh token payload (adds family for rotation tracking)
{ userId, email, role, type: 'refresh', family: 'uuid', iat, exp, sub, jti }

3. Sliding Window Rate Limiter

// Redis Sorted Set algorithm (per-IP, per-endpoint)
// Key: rl:auth:{ip}:{normalized_path}

pipeline.zremrangebyscore(key, '-inf', windowStart);  // 1. Prune old entries
pipeline.zcard(key);                                    // 2. Count remaining
pipeline.zadd(key, now, uuid);                          // 3. Add this request
pipeline.pexpire(key, windowMs);                        // 4. Auto-expire key

// Global: 100 req / 60s per IP
// Auth endpoints: 5 req / 60s per IP (brute-force protection)
// Path normalization: /users/abc-123 → /users/:id (prevents key explosion)

4. Token Blacklist (Logout)

// On logout, add access token to Redis with TTL = remaining lifetime
const remainingSeconds = payload.exp - Math.floor(Date.now() / 1000);
await redis.set(`bl:${accessToken}`, '1', 'EX', remainingSeconds);

// authenticate middleware checks EVERY request:
const isBlacklisted = await redis.get(`bl:${token}`);
if (isBlacklisted) throw new AuthenticationError('Token has been revoked');

5. MFA: TOTP + AES-256-GCM

// TOTP secret encrypted at rest
// encrypt(): iv (12 bytes) + AES-256-GCM + authTag (16 bytes)
// Storage format: "hex(iv):hex(authTag):hex(ciphertext)"
// Key: MFA_ENCRYPTION_KEY env var (256-bit / 64 hex chars)

// Verification allows ±30s window (standard TOTP tolerance)
authenticator.check(userCode, decryptedSecret); // true/false

Middleware Pipeline

Every request passes through a series of checkpoints before reaching your code. Think of it as airport security:

1
Security Headers (Helmet)

Adds protective headers to every response. Think of it as putting on a seatbelt.

2
CORS

Only allows requests from approved websites.

3
Request Logger

Stamps each request with a unique ID and times how long it takes.

4
Rate Limiter

Blocks you if you're making too many requests too fast.

5
Auth Check

Verifies your token is real, not expired, and not blacklisted.

6
Role Check

For admin routes, checks you have the ADMIN role.

7
Input Validation

Checks your data is in the right format before processing.

8
Error Handler

If anything goes wrong at any step, catches it and sends a clean error message.

Execution Order (app.ts)

// ─── Global (applied to ALL routes) ────────────────
app.use(securityHeaders);       // 1. Helmet: CSP, HSTS, X-Frame-Options, nosniff
app.use(corsMiddleware);         // 2. CORS whitelist from CORS_ORIGINS env
app.use(compression());           // 3. Gzip responses
app.use(express.json({limit: '10kb'}));  // 4. Body parser (DoS protection)
app.use(cookieParser());          // 5. Parse Cookie header
app.use(requestLogger);          // 6. X-Request-ID, duration, field redaction
app.use(metricsMiddleware);       // 7. Prometheus counters & histograms
app.use(globalRateLimiter);       // 8. 100 req/60s per IP (Redis sorted set)
app.use(passport.initialize());   // 9. OAuth strategy registration

// ─── Route-specific (applied per-route) ────────────
// authRateLimiter               // 5 req/60s (on /login, /register, /forgot-password)
// authenticate                  // JWT verify + Redis blacklist check
// authorize('ADMIN')            // Role-based access control
// validateRequest(schema)       // Zod validation on body/params/query

// ─── Global catch-all ──────────────────────────────
app.use(errorHandler);           // Normalizes ALL errors to standard envelope

Registration

When someone signs up, here's what happens behind the scenes:

  1. We check if the email is already taken
  2. We scramble the password with Argon2id, an algorithm that uses 64MB of memory per hash, making it extremely hard to crack
  3. We generate a random verification code and email it to them
  4. We create two tokens: a short-lived one for immediate use, and a long-lived one for refreshing
  5. We log the event for auditing

See the Authentication Lifecycle section for the full registration flow diagram. Key implementation details:

  • Email verification token: crypto.randomBytes(32).toString('hex') → 64 hex chars
  • Stored as SHA-256 hash (if DB is breached, raw tokens are useless)
  • Email sent via void emailService.sendVerificationEmail() (fire-and-forget, errors logged but don't break registration)
  • ConflictError (409) on duplicate email, not generic 400

Login & MFA

Regular Login

You enter your email and password. If correct, you get your tokens and you're in.

What if someone guesses wrong?

After 5 wrong guesses, the account locks for 15 minutes. This prevents hackers from trying thousands of passwords.

Two-Factor Authentication (MFA)

If you've turned on MFA, logging in with your password isn't enough. You also need a 6-digit code from an app like Google Authenticator. This code changes every 30 seconds. Even if a hacker knows your password, they can't get in without your phone.

You also get 8 backup codes when you set up MFA. Each can be used once if you lose your phone.

Account Lockout Implementation

// Redis-backed counter with TTL (survives server restarts)
const key = `failed_login:${userId}`;
const attempts = await redis.incr(key);     // Atomic increment
await redis.expire(key, 900);               // 15 min TTL

if (attempts >= 5) {
  await prisma.user.update({
    where: { id: user.id },
    data: { lockedUntil: new Date(Date.now() + 15 * 60 * 1000) },
  });
}

MFA Challenge Flow

When user.mfaEnabled === true, login returns a short-lived MFA challenge token (type: mfa_challenge, 15min expiry) instead of full tokens. The client must then call POST /mfa/verify-login with this token + a valid TOTP code to receive full access/refresh tokens.

TOTP secrets stored encrypted: AES-256-GCM(secret, MFA_ENCRYPTION_KEY)iv:authTag:ciphertext. Decrypted only during verification.

Token System

Access Token (Short Pass)

Lives for 15 minutes. Sent with every request. If it gets stolen, it's only useful for 15 minutes. Signed with RSA cryptography so it's impossible to forge.

Refresh Token (Long Pass)

Lives for 7 days. Only used to get new access tokens. Every time it's used, it self-destructs and a new one is created. If someone tries to reuse an old one, all sessions get killed.

Blacklist (Logout)

When you log out, your access token goes on a "banned list" in Redis. Any request with that token is immediately rejected.

Token Lifecycle State Machine

ACTIVE ──── refresh ───> NEW ACTIVE (same family, new jti) | | | old token: | old token: | revokedAt = now() | replacedBy = newToken.id v v REVOKED REVOKED | | if reused: v ENTIRE FAMILY REVOKED ← theft detection: all tokens in family killed Key fields per RefreshToken record: tokenHash: SHA-256(jwt) Never store raw JWT family: uuid Groups all rotated tokens expiresAt: DateTime From JWT exp claim revokedAt: DateTime? null = active replacedBy: String? Points to successor token ID

Token Cleanup Job

// Runs hourly via setInterval (unref'd so it won't prevent process exit)
await prisma.refreshToken.deleteMany({
  where: { OR: [
    { expiresAt: { lt: now } },                      // Expired tokens
    { revokedAt: { not: null, lt: revokedCutoff } },  // Revoked >24h ago
  ]},
});

Password Reset

  1. User clicks "Forgot Password" and enters their email
  2. We always say "check your email" even if the email doesn't exist, so hackers can't check which emails are registered
  3. We email a secret reset link (valid for 1 hour)
  4. User clicks the link, enters a new password
  5. We update the password and log out all devices (all refresh tokens get revoked)

Two-step process: POST /auth/forgot-password (generates token) → POST /auth/reset-password/:token (consumes token). The reset token is a 32-byte random hex string, stored as SHA-256. Expiry: 1 hour (PASSWORD_RESET_EXPIRY_MS = 60 * 60 * 1000). After reset, all user's refresh tokens are revoked with revokedAt = now() to force re-authentication on every device.

OAuth (Google & GitHub)

Users can sign in with their Google or GitHub accounts instead of creating a password. When they click "Login with Google":

  1. We redirect them to Google's login page
  2. Google verifies them and sends us back their email/name
  3. If they're new, we create an account. If they exist, we link the accounts
  4. They get their tokens and are logged in

This is optional. It only works if you configure Google/GitHub API keys in the environment variables.

Implemented via Passport.js strategies. Each strategy is conditionally loaded. If the client ID env var is not set, the strategy is skipped with a warning log. Account resolution logic: (1) find by OAuthAccount(provider, providerId), (2) find by email match, (3) create new user with emailVerified: true.

Admin Panel

Admin users (role: ADMIN) get access to extra endpoints:

  • User Management: list all users, search, filter, lock/unlock accounts, change roles
  • Audit Logs: view every security event (who logged in, from where, when)
  • Dashboard: 7-day statistics on signups, logins, and active users

Regular users get a "403 Forbidden" error if they try to access these.

All admin routes are guarded by authenticate + authorize('ADMIN') middleware. The authorize middleware checks req.userRole (set by authenticate). The admin service supports paginated queries with Zod-validated params: page, limit (max 100), role filter, status filter (active/locked/deleted), dateFrom/dateTo, search (email fuzzy match), sortBy/sortOrder.

All API Endpoints

Authentication

MethodEndpointAuthDescription
POST/api/v1/auth/registerPublicRegister new account
POST/api/v1/auth/loginPublicLogin with email & password
POST/api/v1/auth/refreshPublicExchange refresh token for new pair
POST/api/v1/auth/logoutAuthBlacklist current access token
POST/api/v1/auth/verify-email/:tokenPublicConfirm email address
POST/api/v1/auth/forgot-passwordPublicInitiate password reset
POST/api/v1/auth/reset-password/:tokenPublicComplete password reset

Multi-Factor Authentication

MethodEndpointAuthDescription
POST/api/v1/mfa/enableAuthGenerate TOTP secret & backup codes
POST/api/v1/mfa/verifyAuthActivate MFA with TOTP code
POST/api/v1/mfa/verify-loginPublicComplete MFA login challenge
POST/api/v1/mfa/verify-backup-codePublicUse backup code for MFA
POST/api/v1/mfa/disableAuthDisable MFA (requires password + TOTP)

User & Sessions

MethodEndpointAuthDescription
GET/api/v1/users/meAuthGet current user profile
GET/api/v1/sessionsAuthList active sessions
DEL/api/v1/sessions/:idAuthRevoke a session

Admin (RBAC: ADMIN role required)

MethodEndpointAuthDescription
GET/api/v1/admin/usersAdminList all users (paginated, filterable)
GET/api/v1/admin/users/:idAdminGet user details
PATCH/api/v1/admin/users/:idAdminUpdate user (role, lock, verify)
GET/api/v1/admin/audit-logsAdminView audit trail
GET/api/v1/admin/dashboardAdminDashboard stats (7-day trends)

System

MethodEndpointDescription
GET/healthHealth check (Docker liveness probe)
GET/metricsPrometheus metrics
GET/api-docsSwagger UI (interactive docs)
GET/demoInteractive demo page

Error Handling

Every error returns a consistent format so your frontend always knows what to expect:

{
  "success": false,
  "error": {
    "code": "AUTHENTICATION_ERROR",
    "message": "Invalid email or password",
    "statusCode": 401
  }
}

Errors never expose sensitive information. In production, unexpected errors return a generic message while the full details are logged server-side.

Error Class Hierarchy

AppError (base)
  ├── AuthenticationError  // 401 - invalid token, wrong password
  ├── AuthorizationError   // 403 - insufficient role
  ├── ValidationError      // 400 - Zod schema failures (includes field errors[])
  ├── NotFoundError        // 404 - user/resource not found
  ├── ConflictError        // 409 - duplicate email
  ├── RateLimitError       // 429 - too many requests
  └── InternalError        // 500 - isOperational=false (masked in prod)

The global errorHandler middleware catches all errors, maps Prisma errors (P2002 → 409, P2025 → 404), Zod errors to ValidationError, and masks non-operational errors in production (returns generic "An unexpected error occurred" while logging full stack trace).

Docker & Deployment

The entire system runs with one command:

docker-compose up

This starts 5 services:

  1. App (port 3000): the auth service itself
  2. PostgreSQL (port 5432): the database
  3. Redis (port 6379): fast cache for sessions and rate-limiting
  4. Prometheus (port 9090): collects performance metrics
  5. Grafana (port 3001): dashboards to monitor everything

Multi-Stage Dockerfile

# Stage 1: Build (full Node, all devDeps)
FROM node:20-alpine AS builder
RUN npm ci                    # Deterministic install
RUN npx prisma generate        # Generate typed client
RUN npm run build              # tsc → dist/

# Stage 2: Production (minimal image)
FROM node:20-alpine
RUN apk add --no-cache dumb-init openssl
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/prisma ./prisma
COPY --from=builder /app/public ./public
USER node                      # Non-root user
CMD ["dumb-init", "node", "dist/server.js"]  # PID 1 handler

docker-compose.yml

5 services on a shared bridge network (auth-network). App depends on postgres/redis health checks. Postgres uses pg_isready, Redis uses redis-cli ping. Data persisted via named volumes. Docker entrypoint generates RSA keys if missing, runs Prisma migrations, and seeds in dev.

Monitoring

The service automatically tracks its own health:

  • Structured Logs: every request is logged as JSON with a unique ID, timing, and user info
  • Prometheus Metrics: request counts, response times, error rates, active sessions
  • Grafana Dashboards: visual graphs showing traffic, latency, and auth success/failure rates
  • Health Check: a /health endpoint that Docker uses to know if the service is alive

Prometheus Metrics Collected

MetricTypeLabels
http_requests_totalCountermethod, path, status_code
http_request_duration_secondsHistogrammethod, path
auth_login_totalCounterresult (success/failure/locked)
auth_active_sessionsGauge-
auth_rate_limit_hits_totalCounter-
Default process metricsVariousCPU, memory, event loop lag, GC

Logging via Pino (JSON in production, pretty-printed in dev). Automatic field redaction for passwords, tokens, and secrets. Every log entry includes requestId for distributed tracing.

Testing

The entire system has 136 automated tests that verify everything works correctly:

TypeCountWhat It Tests
Unit Tests86Individual functions in isolation (password hashing, JWT, crypto, validation schemas)
Integration Tests40Full HTTP requests through the server (register, login, admin, MFA)
E2E Tests10Complete user journey: register → verify email → login → enable MFA → MFA login → refresh → sessions → logout

Run them all with npm test. The project requires 80% code coverage to pass.

Test Architecture

// Coverage thresholds (jest.config.js)
coverageThreshold: {
  global: { branches: 70, functions: 80, lines: 80, statements: 80 }
}

// Test commands
npm run test:unit        // src/**/*.test.ts (86 tests)
npm run test:integration // tests/integration/ (40 tests, real DB)
npm run test:e2e         // tests/e2e/ (10 steps, sequential flow)

Key Testing Patterns

  • Unit tests: Pure function testing (argon2 roundtrip, JWT sign/verify, crypto encrypt/decrypt, Zod schema edge cases). No DB or network calls.
  • Integration tests: Supertest against real Express app with real Prisma (test DB) and real Redis. afterEach cleans all tables.
  • E2E tests: Sequential 10-step journey using setup-e2e.ts (no afterEach cleanup, only afterAll) so state persists between steps.
  • Test helpers: factories.ts provides createTestUser() and createTestApp() with TestAgent for cookie/header persistence.
  • TypeScript config: tsconfig.test.json extends base with noUnusedLocals: false to allow test-scoped variables.

Auth Microservice • 136 tests • 25 endpoints • 14 security layers
Built with Node.js, TypeScript, PostgreSQL, Redis, and Docker