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, optional MFA fields, and separatefirstName/middleName/lastNamefields (deliberately not one combined name field). - 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.
- Custom Field
- A project-defined registration field beyond the fixed name/email/password — a label, a type (text, number, date, dropdown, etc.), and whether it's required. Submitted values land on the
Useras plain profile data, not a separate onboarding step.
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 |
| Password strength | Backend only enforces 8+ characters. Every first-party form (portal, Sentinel) additionally requires 4 of 5 checks — length, upper, lower, number, symbol — via a client-side strength meter before allowing submit; the SDK doesn't enforce this itself, it's a convention |
| 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 open to any origin (see below — no cookies are used, so Origin isn't a security boundary here) |
| 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.
Why CORS has no allowlist
Every route reflects whatever Origin a request comes from, rather than checking it against a fixed list. That's deliberate, not an oversight: a publicKey is documented as safe to ship in client code, so it's already expected to sit in the public JS bundle of every project's site — an attacker doesn't need a browser to read it or call the API with it, so an Origin allowlist blocks nothing a real attacker couldn't trivially route around with curl. It only ever blocked legitimate browsers on domains nobody had manually added yet, which doesn't scale once projects are third-party apps you don't control the domain of. The actual security boundary is unchanged: every request still needs a valid x-dravyn-key, and every privileged one needs a valid bearer token or API key — checked per-request, regardless of where it came from. This is safe specifically because nothing here uses cookies (no Set-Cookie anywhere in the codebase); all tokens are explicit headers/body fields the browser never attaches automatically, so there's no ambient-credential/CSRF surface for an open CORS policy to expose.
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+), firstName?, middleName?, lastName?, customFields? | 201 { userId } — sends OTP |
GET /fields | — | — | the project's custom registration fields ([] if none defined) |
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, firstName, middleName, lastName, customFields, 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 |
POST /projects/:id/fields | label, type, options?, required? | field — key is auto-slugified from label, deduped if taken |
GET /projects/:id/fields | — | the project's custom registration fields |
PATCH /projects/:id/fields/:fieldId | label?, options?, required?, order? | field — type can't be changed after creation |
DELETE /projects/:id/fields/:fieldId | — | 204 |
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 live on npm, in its own repo (Dravyn-tech/auth-npm-, gitignored from this monorepo). The portal and dashboard consume it as a normal published dependency (a semver range in package.json, not a workspace link) — that's deliberate, since packages/auth-js doesn't exist on a fresh clone and a workspace link would break the Vercel build.
Framework-agnostic core
// pnpm add @dravyn/auth-js
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
});
// fields is [] for a project with no custom fields defined — safe to always call
const fields = await auth.getCustomFields();
await auth.register('jane@site.com', 'correct-horse-battery', { firstName: 'Jane', lastName: 'Doe' }, {
plan_tier: 'Pro', // keyed by each field's `key`, validated server-side against `fields`
});
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.
- Registration fields — add extra signup fields beyond the fixed First/Middle/Last name, email, and password: pick from a preset (phone — digit-validated, company, country — a full world-country dropdown, website, date of birth…) or define one manually with its own label, type, and — for a dropdown — options. Any app using this project's public key picks these up automatically via
getCustomFields(); no code changes needed on the consuming side when a field is added or removed. Every first-party form renders them in the same standard order: name fields, email, project fields, then password + confirm password last. - 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 strong MFA_ENCRYPTION_KEY, and rotate ADMIN_BOOTSTRAP_KEY away from the development placeholder. No CORS configuration needed — see §05 for why.
What's left
One thing, 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.
Everything else from the original product plan — RBAC, API keys, webhooks, audit log, MFA, organisations, per-project branding, Google/GitHub OAuth, project-defined custom registration fields, and a self-serve portal (and published SDK) to manage all of it — is implemented and was exercised end-to-end through an actual browser session, not just curl.