Phase 1 - Multi-tenant identity and access¶
Goal: Support organizations using the platform with authentication, authorization, and tenant isolation.
Status: ✅ Complete
TLDR¶
Phase 1 is complete. Here's what you built:
- Multi-tenant user model with RBAC (4 roles)
- JWT authentication with access + refresh token rotation
- Password reset and change flows
- Stripe-style API keys with hash-only storage
- Tenant isolation proven at every layer (query, guard, API)
- Seed script with 5 users across 3 tenants with different roles
- All endpoints properly guarded
What was built¶
Data model¶

Four new tables added to the Tenant model from Phase 0:
- users - global accounts with bcrypt-hashed passwords
- memberships - join table connecting users to tenants with roles
- api_keys - server-to-server authentication tokens (SHA-256 hashed)
- refresh_tokens - stateful token tracking for session management
- password_reset_tokens - single-use tokens for forgot-password flow
Authentication¶
Two authentication mechanisms for two audiences:
- JWT (access + refresh tokens) - for dashboard/admin users via browser
- Access token: 15 min, stateless, no DB lookup per request
- Refresh token: 7 days, stateful (hashed in DB), token rotation on each refresh
- Separate signing secrets for access and refresh tokens
- API keys - for server-to-server access (Stripe-style
mp_live_...keys) - Raw key shown once at creation, SHA-256 hash stored
- Key prefix stored for identification without exposure
- Supports expiration and revocation
Authorization (RBAC)¶
Four roles enforced via guards:
| Role | Manage users | Manage billing | Use APIs | Delete tenant |
|---|---|---|---|---|
| OWNER | ✅ | ✅ | ✅ | ✅ |
| ADMIN | ✅ | ✅ | ✅ | ❌ |
| DEVELOPER | ❌ | ❌ | ✅ | ❌ |
| BILLING | ❌ | ✅ | ❌ | ❌ |
Tenant isolation¶
- Users see only tenants they belong to (query-level filtering)
TenantGuardvalidatesx-tenant-idheader + membership on every scoped requestRolesGuardchecks user's role within the specific tenant- API keys are scoped to a single tenant - no cross-tenant access
Password management¶
- Registration - creates user + tenant + OWNER membership in one transaction
- Login - validates credentials, returns access + refresh tokens
- Forgot password - generates crypto-random reset token (15 min, single-use)
- Reset password - validates token, updates password, revokes all sessions
- Change password - requires current password, revokes other sessions
API endpoints¶
Auth (public)¶
| Method | Path | Description |
|---|---|---|
| POST | /auth/register | Create user + tenant, return tokens |
| POST | /auth/login | Validate credentials, return tokens |
| POST | /auth/refresh | Exchange refresh token for new pair |
| POST | /auth/forgot-password | Generate reset token |
| POST | /auth/reset-password | Reset password with token |
Auth (protected)¶
| Method | Path | Description |
|---|---|---|
| GET | /auth/me | Current user profile |
| POST | /auth/change-password | Change password |
| POST | /auth/logout | Revoke refresh token |
| POST | /auth/logout-all | Revoke all sessions |
Tenants (protected)¶
| Method | Path | Guards | Description |
|---|---|---|---|
| POST | /tenants | JWT | Create tenant (user becomes OWNER) |
| GET | /tenants | JWT | List user's tenants |
| GET | /tenants/slug/:slug | JWT | Look up by slug |
| GET | /tenants/:id | JWT + Tenant | Get tenant details |
| PATCH | /tenants/:id | JWT + Tenant + OWNER/ADMIN | Update tenant |
| DELETE | /tenants/:id | JWT + Tenant + OWNER | Cancel tenant |
Users (protected)¶
| Method | Path | Guards | Description |
|---|---|---|---|
| POST | /users | JWT + Tenant + OWNER/ADMIN | Create user in tenant |
| GET | /users/me | JWT | Own profile |
| GET | /users/:id | JWT | User by ID |
| PATCH | /users/:id | JWT | Update profile |
API Keys (protected)¶
| Method | Path | Guards | Description |
|---|---|---|---|
| POST | /api-keys | JWT + Tenant + OWNER/ADMIN | Create key (shown once) |
| GET | /api-keys | JWT + Tenant + OWNER/ADMIN | List keys |
| DELETE | /api-keys/:id | JWT + Tenant + OWNER/ADMIN | Revoke key |
Key decisions¶
| Decision | Why |
|---|---|
| Multi-tenant users (one login, many orgs) | Matches Slack/GitHub model, more flexible than single-tenant |
| Memberships as join table | Users can have different roles in different tenants |
| Fixed roles (enum) over dynamic RBAC | Four roles is sufficient for a billing platform, no over-engineering |
| Dual JWT tokens (access + refresh) | Short-lived access limits stolen token damage, refresh enables session management |
| Separate secrets for access and refresh | Compromising one doesn't compromise the other |
| Token rotation on refresh | Detects stolen refresh tokens via reuse detection |
| API keys as SHA-256 hashes | Same pattern as Stripe - database breach doesn't expose keys |
| Soft-delete on tenants | Billing compliance requires data retention |
| Password reset via DB tokens (not JWT) | Audit trail, single-use enforcement, explicit revocation |
| x-tenant-id header over URL param | Keeps routes clean, matches Stripe's Connected Accounts pattern |
Seed data¶
All passwords: DevPass123
| User | Acme Corp | Globex | Stark |
|---|---|---|---|
| alice@meterplex.dev | OWNER | ADMIN | - |
| bob@meterplex.dev | DEVELOPER | OWNER | - |
| carol@meterplex.dev | BILLING | - | OWNER |
| dave@meterplex.dev | DEVELOPER | DEVELOPER | - |
| eve@meterplex.dev | - | BILLING | ADMIN |
Gotchas encountered¶
- JWT hash collision - two tokens signed at the same second with identical payload produce the same hash. Fixed by adding
jti(JWT ID) claim with random bytes. @nestjs/jwttype mismatch -expiresInacceptsstring | numberat runtime but TypeScript overloads reject strings. Fixed by converting duration to seconds.- Route ordering matters -
/tenants/me/contextmust be defined before/tenants/:idor NestJS interprets "me" as a UUID parameter. - Guard chain order -
JwtAuthGuardmust run beforeTenantGuardandRolesGuardbecause they depend onrequest.userbeing set.
Post-Phase 1: Audit Log Interceptor¶
Added as a cross-cutting concern before Phase 2 begins. Every mutation (POST, PATCH, PUT, DELETE) is recorded to an immutable audit_logs table in Postgres.
What it captures: actor (user or API key), action (create/update/delete), resource type and ID, tenant, JSONB changes payload, IP address, user agent, and correlation ID.
What it skips: GET requests, health checks, auth endpoints, and routes with @SkipAudit().
How it works: Global NestJS interceptor using RxJS tap(). Runs after the handler completes. Fire-and-forget - audit failures are logged to stdout but never block the response.
Sensitive fields stripped: passwords, tokens, key material are automatically sanitized from the changes payload before storage.
See Audit Log documentation for the full schema, query examples, and extension guide.