Dravyn Auth, end to end
Everything the backend does, how the pieces fit together, and how a developer — including future-you — actually integrates it. Written against the code as it exists today, not the plan.
What Dravyn Auth is
A self-hosted authentication service, built once and reused by every Dravyn product — and sold to other developers the same way Clerk or Auth0 is, complete with a self-serve signup portal.
Instead of every new app (ClassSync, Cashlet, whatever comes next) reimplementing registration, password hashing, email OTP verification, JWTs, and session handling, they all point at one running instance of Dravyn Auth. Each app registers as a project and gets its own key — its users are fully isolated from every other project's, even though they share the same database and server.
It's no longer just an internal tool, either: the developer portal means an outside developer can sign up, create a project, brand it with their own name and color, generate API keys, register webhooks, define roles, and manage it all themselves — the same product-as-a-service loop as the tools it's modeled on.
It is not a wrapper around Supabase Auth or Firebase. It's a plain NestJS HTTP API backed by your own Postgres database, with no third-party auth dependency in the request path — the only external free services it touches are Postgres, Resend (email), and Google/GitHub's OAuth endpoints, all swappable via environment variables.
Core concepts
These nouns explain almost everything else in this document.
- Project
- One tenant — one app integrating Dravyn Auth. Has a
publicKey(pk_…, safe to ship in client code), asecretKey(sk_…, shown once), and now optionaldisplayName/primaryColorbranding used in every email it sends. Adefaultproject is seeded for zero-config local use; a reservedplatformproject is seeded for the portal itself. - Owner
- A developer using Dravyn Auth as a product. Not a separate system — an Owner is just a regular User who happens to belong to the reserved
platformproject. They sign up and log in through the exact same/v1/auth/*endpoints as anyone else, then use/v1/owner/*routes to manage the projects they own. - User
- Scoped to a project — the same email can exist once per project, not once globally. Has an optional
passwordHash(null for OAuth-only accounts), anemailVerifiedAttimestamp gating login, and optional MFA fields. - Session
- One row per refresh-token family. Holds a hash of the current refresh secret, device metadata, and timestamps. Rotates in place on every
/refreshcall rather than accumulating rows. - Access / Refresh token
- A short-lived RS256 JWT (default 15 min) proves identity on every request — now carrying
rolesandpermissionsclaims too. A longer-lived opaque refresh token (default 30 days) exchanges for a new pair. - Role / Permission
- A Role is a named set of permission strings (
"admin"→["billing.manage"]), scoped to a project. Assigning a role to a user embeds its permissions directly in their next access token — no extra API call needed to check them. - API Key
- A machine-to-machine credential scoped to a project (
ak_…). sha256-hashed at rest, authenticates server-to-server calls viax-api-keyinstead of a user session. - Webhook
- A URL a project registers to receive HMAC-signed POST requests when events happen (user created, logged in, etc). Delivery retries on failure with exponential backoff.
- Organization
- A group of a project's end users who share access to something — like a workspace. Not to be confused with a Project or an Owner. Members join via emailed invite links.
- OTP
- A 6-digit, bcrypt-hashed, 10-minute-lived code used for email verification and password reset.
Architecture & repo layout
Everything lives in one pnpm workspace at ~/Documents/Dravyn:
Inside backend/, each capability is its own NestJS module: auth/ (controller, service, OTP, tokens, OAuth strategies), sessions/, projects/, owner/, api-keys/, webhooks/, audit-log/, rbac/, mfa/, organizations/, management/, mail/, prisma/, plus cross-cutting common/ (guards, filters, middleware).
Stack per layer
| Layer | Choice | Why |
|---|---|---|
| API framework | NestJS 10 | DI, guards, and structured modules scale better than bare Express past a few routes |
| Database | PostgreSQL | Relational integrity for the projectId/email uniqueness constraint that multi-tenancy depends on |
| ORM | Prisma 5 | Type-safe queries, migration history in backend/prisma/migrations |
| Validation | Zod, via nestjs-zod | Schemas double as the source for Swagger's request bodies |
| Resend | Free tier (3,000/mo); falls back to console-logging the OTP in dev when unset | |
| MFA | otplib + qrcode | RFC 6238 TOTP, server-rendered QR as a data URI |
| Docs | Swagger / OpenAPI | Auto-generated at /api/docs, interactive "try it out" |
Feature matrix
What's actually running today versus what's still a config decision away from being real (billing).
Shipped
Not built
Billing is the one item that can't be built blind — it needs a real Stripe account, price IDs, and a webhook secret. Everything else from the original roadmap is implemented and verified end-to-end, including through an actual browser.
Security model
| Mechanism | Detail |
|---|---|
| Password storage | bcrypt, cost factor 12 |
| OTP storage | bcrypt, cost factor 10 · 6 digits · 10 min expiry · max 5 attempts · 60s resend cooldown |
| Access token | RS256 JWT, 2048-bit keypair · 15 min default lifetime (per-project configurable) · issuer claim verified |
| Refresh token | Opaque sessionId.secret · secret is 32 random bytes · sha256-hashed at rest · rotates on every use |
| Refresh reuse | A stale or forged refresh token immediately revokes that session |
| MFA secret | AES-256-GCM encrypted at rest (MFA_ENCRYPTION_KEY), never stored plain; 8 bcrypt-hashed one-time backup codes |
| MFA challenge token | A distinct, 5-minute JWT with an mfaChallenge claim — explicitly rejected by every other endpoint's auth guard, so it can never be replayed as a real session |
| API keys | sha256-hashed at rest; only an 12-char prefix is ever shown again after creation |
| Webhooks | Each payload HMAC-SHA256 signed with a per-webhook secret, sent as x-dravyn-signature |
| Transport | helmet() security headers · CORS allowlist via CORS_ORIGINS |
| Tenant isolation | Every query is scoped by projectId; enforced at the schema level via a composite unique constraint, not just application logic |
Why refresh rotation matters
Each successful /refresh call generates a new secret and overwrites the session's stored hash — the old refresh token stops working the instant a new one is issued. If that old token is ever presented again, the session is revoked outright rather than just rejected. Verified during development: reusing a rotated-out token returns 401 and the session disappears from /v1/auth/sessions.
Why the MFA challenge token can't be reused
Login for an MFA-enabled user returns { mfaRequired: true, mfaToken } instead of real tokens. That mfaToken is structurally a JWT signed with the same key as a real access token — so JwtStrategy explicitly checks for the mfaChallenge claim and throws 401 MFA_REQUIRED if it's present, on every route except the one dedicated, manually-verified /mfa/challenge endpoint. Verified: presenting an mfaToken to /v1/auth/me is rejected.
Rate limits
| Scope | Limit |
|---|---|
| Every route, default | 60 req / 60s per IP |
register, verify-otp, login, refresh, reset-password, mfa/challenge | 8 req / 60s |
resend-otp | 3 req / 60s + a separate 60s per-user cooldown |
forgot-password | 5 req / 60s |
Getting started
- Node 20+, pnpm (
corepack enable), and a Postgres database — local via Homebrew, or free-tier Supabase for a hosted one. cd backend && cp .env.example .envnode scripts/generate-keys.js— prints a fresh RS256 keypair, paste into.env.openssl rand -hex 32— paste intoMFA_ENCRYPTION_KEY.pnpm run prisma:migrate --name init && pnpm run seed— creates the schema, thedefaultproject, and the reservedplatformproject (prints its public key — you'll need it for the portal).pnpm run dev:api— API on:4000, docs at/api/docs.- Optional:
cd apps/portal && cp .env.example .env, paste theplatformpublic key intoVITE_PLATFORM_PUBLIC_KEY, thenpnpm run dev— portal on:5174.
Nothing above requires a paid tier. RESEND_API_KEY can stay empty in development — OTP codes print to the terminal instead of being emailed. Google/GitHub OAuth routes work fine unconfigured too; they return a clear OAUTH_NOT_CONFIGURED error rather than crashing the server.
API reference
Every request except the two below is scoped to a project via an x-dravyn-key: pk_… header. Omit it and the seeded default project is used — convenient locally, but a real multi-tenant deployment should always send it.
Auth — /v1/auth
| Route | Auth | Body / params | Returns |
|---|---|---|---|
POST /register | — | email, password (8+), name? | 201 { userId } — sends OTP |
POST /verify-otp | — | email, code (6) | tokens |
POST /resend-otp | — | email, type | 204 |
POST /login | — | email, password | tokens, or { mfaRequired: true, mfaToken } |
POST /refresh | — | refreshToken | new tokens |
POST /logout | — | refreshToken | 204 |
POST /forgot-password | — | email | 204 always — no account-existence leak |
POST /reset-password | — | email, code, newPassword | 204 — revokes every session |
GET /me | Bearer | — | { id, projectId, email, roles, permissions } |
GET /sessions | Bearer | — | active sessions: device, IP, timestamps |
DELETE /sessions/:id | Bearer | — | 204 |
GET /google · /github | — | ?publicKey=pk_… | redirect to provider |
GET /google/callback · /github/callback | — | — | redirect with tokens in the URL fragment |
MFA — /v1/auth/mfa
| Route | Auth | Body | Returns |
|---|---|---|---|
POST /setup | Bearer | — | { secret, otpauthUrl, qrCodeDataUrl } — doesn't enable MFA yet |
POST /verify-setup | Bearer | code | { backupCodes } — enables MFA, codes shown once |
POST /disable | Bearer | — | 204 |
POST /challenge | — | mfaToken, code | tokens — code is a TOTP or backup code |
Organizations — /v1/orgs
| Route | Body / params | Returns |
|---|---|---|
POST / | name | org — caller becomes OWNER |
GET / | — | orgs the caller belongs to |
GET /:id/members | — | members (must belong to the org) |
POST /:id/invites | email | 204 — owner only, emails an invite link |
POST /invites/accept | token | org — must be logged in as the invited email |
DELETE /:id/members/:userId | — | 204 — owner, or self to leave |
Management (API-key auth) — /v1/management
Not a user session — authenticated via x-api-key: ak_… instead of a Bearer token. This is the actual machine-to-machine path.
| Route | Returns |
|---|---|
GET /users | the key's project's users |
POST /users/:userId/revoke-sessions | 204 — kills every session for that user |
Owner (self-serve portal) — /v1/owner
Bearer-authenticated, but the token must belong to a user in the reserved platform project (OwnerGuard). Every route below is additionally scoped so you can only ever touch projectIds you own — a mismatch returns 404, not 403, so ownership is never confirmed or denied to someone probing.
| Route | Body / params | Returns |
|---|---|---|
POST /projects | name | { project, secretKey } |
GET /projects | — | projects you own |
GET /projects/:id | — | one project |
PATCH /projects/:id/branding | displayName?, primaryColor? | updated project |
GET /projects/:id/users | — | that project's end users |
POST /projects/:id/api-keys | name, scopes?, expiresInDays? | { apiKey, rawKey } |
GET /projects/:id/api-keys | — | keys (prefix only) |
DELETE /projects/:id/api-keys/:keyId | — | 204 |
POST /projects/:id/webhooks | url, events[] | { webhook, secret } |
GET /projects/:id/webhooks | — | webhooks |
PATCH /projects/:id/webhooks/:whId | enabled | 204 |
DELETE /projects/:id/webhooks/:whId | — | 204 |
GET /projects/:id/webhooks/:whId/deliveries | — | last 50 delivery attempts |
POST /projects/:id/roles | name, permissions[] | role |
GET /projects/:id/roles | — | roles + assignee counts |
PATCH /projects/:id/roles/:roleId | name?, permissions? | role |
DELETE /projects/:id/roles/:roleId | — | 204 |
POST /projects/:id/users/:userId/roles/:roleId | — | 204 |
DELETE /projects/:id/users/:userId/roles/:roleId | — | 204 |
GET /projects/:id/audit-log | ?limit= | recent events, newest first |
Platform-admin dashboard — /v1/dashboard
Guarded by an x-admin-key header matching ADMIN_BOOTSTRAP_KEY — one shared secret for you, the platform operator, to see every project regardless of owner. Distinct from the owner routes above, which only ever see your own.
| Route | Body / params | Returns |
|---|---|---|
POST /projects | name | { project, secretKey } |
GET /projects | — | every project + user counts |
GET /projects/:id/users | — | users in that project |
Error shape
Every non-2xx response — validation failures, auth errors, rate limits — has the same four fields:
{
"error": "Incorrect code",
"code": "OTP_INVALID",
"details": {},
"requestId": "dBMpi9zce0Kf4K_bdvBju"
}
Common code values worth branching on: VALIDATION_ERROR, EMAIL_TAKEN, INVALID_CREDENTIALS, EMAIL_NOT_VERIFIED, OTP_EXPIRED, OTP_INVALID, OTP_LOCKED, OTP_COOLDOWN, RATE_LIMITED, OAUTH_NOT_CONFIGURED, MFA_REQUIRED, MFA_CODE_INVALID, API_KEY_INVALID, NOT_PLATFORM_ACCOUNT, PROJECT_NOT_FOUND.
Using the SDK
@dravyn/auth-js is already taken on npm — but by an older, incompatible prototype from a separate Dravyn-tech/auth repo (different user shape, no OTP flow, assumes a role field that doesn't exist in this backend). It has not been updated to match this API. Until the SDK below is published to supersede it, use it via the monorepo workspace link, as the portal and dashboard apps already do.
Framework-agnostic core
// pnpm add @dravyn/auth-js (workspace:*, until published)
import { DravynAuthClient } from '@dravyn/auth-js';
const auth = new DravynAuthClient({
baseUrl: 'http://localhost:4000',
publicKey: 'pk_your_project_key', // omit to use the default project
});
await auth.register('jane@site.com', 'correct-horse-battery');
await auth.verifyOtp('jane@site.com', '123456'); // signs in on success
// login() returns the user OR { mfaRequired: true, mfaToken }
const result = await auth.login('jane@site.com', 'correct-horse-battery');
if ('mfaRequired' in result) {
await auth.completeMfaChallenge(result.mfaToken, '482913');
}
// roles/permissions are right there on the user object — no extra call
const me = auth.getUser(); // { id, email, roles: ['admin'], permissions: ['billing.manage'], ... }
MFA & Organizations
const { qrCodeDataUrl, secret } = await auth.setupMfa();
// render qrCodeDataUrl as an <img>, user scans it, then:
const { backupCodes } = await auth.confirmMfaSetup('482913'); // show these once
const org = await auth.createOrg('Acme Workspace');
await auth.inviteOrgMember(org.id, 'teammate@acme.com');
React
import { AuthProvider, useAuth } from '@dravyn/auth-js/react';
function Root() {
return (
<AuthProvider baseUrl="http://localhost:4000" publicKey="pk_…">
<App />
</AuthProvider>
);
}
function LoginForm() {
const { login, completeMfaChallenge, user, isAuthenticated, logout } = useAuth();
}
Wrap anything behind auth in <ProtectedRoute fallback={<LoginForm />}>. Tokens persist to localStorage by default; pass a storage adapter backed by @react-native-async-storage/async-storage for React Native / Expo (ClassSync's stack).
Completing an OAuth redirect
Google/GitHub callbacks redirect with tokens in the URL fragment (never sent to servers or logged) — call this once on that page:
const user = await auth.completeOAuthFromUrl(); // reads window.location, signs in
Platform-admin dashboard
A small React/Vite app at apps/dashboard (port 5173) for you, the operator, to see every project regardless of who owns it — create projects, copy key pairs, browse users. Gated by ADMIN_BOOTSTRAP_KEY, remembered in localStorage. This is the "god mode" view; day-to-day project management is what the portal below is for.
Developer portal
This is the page: a real product-as-a-service signup flow where any developer creates their own account, spins up projects, and customizes how Dravyn Auth presents itself to their users.
Lives at apps/portal (port 5174), built entirely on @dravyn/auth-js and @dravyn/ui — it doubles as the SDK's own proving ground. Sign-up/login is genuinely full auth: OTP verification, forgot/reset password, and MFA challenge screens all wired through the SDK exactly as an external consumer would use it.
What an owner can do, per project
- Branding — set a display name and accent color, with a live preview of the actual verification email. This is what makes emails say "Acme Inc" in Acme's orange instead of "Dravyn Auth" in teal.
- Users — read-only list of that project's end users.
- API Keys — generate/revoke machine-to-machine credentials.
- Webhooks — register endpoints, pick events, inspect delivery history (status, response code, retry schedule) in a modal.
- Roles — define roles with comma-separated permissions, assign them to users by email.
- Audit Log — chronological feed of everything that's happened in the project.
Why owners are just users
Rather than build a second parallel identity system, an "owner" is a User row that happens to belong to the reserved platform project (seeded automatically, see Getting started). Signing up as a developer goes through the exact same /v1/auth/register → OTP → /v1/auth/login flow as any end user of any other project — Dravyn Auth authenticates its own customers using itself. The only new code is the OwnerGuard, which checks that a request's JWT belongs to a platform-project user before allowing it near /v1/owner/*, and that any projectId in the URL is actually owned by that caller.
pnpm run dev:api, then cd apps/portal && pnpm run dev. Register a new account, verify the OTP printed in the API's console, create a project, and open its Branding tab — the preview panel updates live as you pick a color.
@dravyn/ui
@dravyn/ui@0.2.1 is the component library both frontends are built from — Button, Card, Input, Textarea, Badge, Alert, Avatar, Toggle, Modal, Spinner — dark-first, zero runtime dependencies besides React, published and live on npm today.
import '@dravyn/ui/tokens'; // once, at the root — loads the CSS variables every component reads
import { Button, Card, Badge } from '@dravyn/ui';
Component styles auto-inject on import — no separate CSS file to remember. Requires React 18+ (for useId, used internally to keep field ids collision-free). Card now also accepts a plain style prop for one-off spacing, added while building the portal.
Deploying for free
| Piece | Where | Cost |
|---|---|---|
| API | Railway free trial credit, or any Node host | free tier |
| Database | Supabase | free tier |
| Admin dashboard | Vercel | free tier |
| Developer portal | Vercel (separate project) | free tier |
| Resend | 3,000/mo free |
Same environment variables as local dev; set NODE_ENV=production, a real CORS_ORIGINS allowlist (both frontend origins), a strong MFA_ENCRYPTION_KEY, and rotate ADMIN_BOOTSTRAP_KEY away from the development placeholder.
What's left
Two things, both requiring your input rather than more code:
- Stripe billing — needs a real Stripe account, price IDs, and a webhook secret before it can be built.
- The
@dravyn/auth-jsnpm conflict — the SDK described in §08 is complete and verified, but hasn't been published, because the name is already taken by an incompatible prototype from a different repo. Resolving this — republish over it, or ship under a new name — is a decision, not a build task.
Everything else from the original product plan — RBAC, API keys, webhooks, audit log, MFA, organisations, per-project branding, and a self-serve portal to manage all of it — is implemented and was exercised end-to-end through an actual browser session, not just curl.