Dravyn Tech · Authentication-as-a-Service

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.

NestJS + Prisma + PostgreSQL RS256 JWT RBAC · MFA · Webhooks Multi-tenant + self-serve portal Core feature set shipped @dravyn/ui 0.2.1 on npm

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), a secretKey (sk_…, shown once), and now optional displayName/primaryColor branding used in every email it sends. A default project is seeded for zero-config local use; a reserved platform project 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 platform project. 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), an emailVerifiedAt timestamp 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 /refresh call rather than accumulating rows.
Access / Refresh token
A short-lived RS256 JWT (default 15 min) proves identity on every request — now carrying roles and permissions claims 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 via x-api-key instead 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:

backend/ # NestJS + Prisma — the API described in this guide apps/ dashboard/ # React + Vite — platform-admin UI (sees every project) portal/ # React + Vite — self-serve developer portal (§10) packages/ auth-js/ # @dravyn/auth-js — client SDK, not yet published ui/ # @dravyn/ui — component library, published to npm

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

LayerChoiceWhy
API frameworkNestJS 10DI, guards, and structured modules scale better than bare Express past a few routes
DatabasePostgreSQLRelational integrity for the projectId/email uniqueness constraint that multi-tenancy depends on
ORMPrisma 5Type-safe queries, migration history in backend/prisma/migrations
ValidationZod, via nestjs-zodSchemas double as the source for Swagger's request bodies
EmailResendFree tier (3,000/mo); falls back to console-logging the OTP in dev when unset
MFAotplib + qrcodeRFC 6238 TOTP, server-rendered QR as a data URI
DocsSwagger / OpenAPIAuto-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

Email + password registration with OTP verificationshipped
Google OAuth · GitHub OAuthshipped
RS256 JWT access tokensshipped
Refresh token rotation + reuse/theft detectionshipped
Forgot / reset passwordshipped
Session listing + per-session revokeshipped
Multi-tenancy (Projects, key-based isolation)shipped
Per-project email branding (name + accent color)shipped
RBAC — roles, permissions, JWT-embedded claimsshipped
Machine-to-machine API keysshipped
Webhooks — HMAC-signed, retried with backoffshipped
Audit log of auth events per projectshipped
MFA (TOTP) with backup codesshipped
Organisations — invite-based member groupingshipped
Self-serve developer portal (signup → branded project)shipped
REST API + Swagger docsshipped
Platform-admin dashboardshipped
JS/React SDKshipped, unpublished

Not built

Stripe billing / paid tiersneeds your Stripe keys
The only real gap

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

MechanismDetail
Password storagebcrypt, cost factor 12
OTP storagebcrypt, cost factor 10 · 6 digits · 10 min expiry · max 5 attempts · 60s resend cooldown
Access tokenRS256 JWT, 2048-bit keypair · 15 min default lifetime (per-project configurable) · issuer claim verified
Refresh tokenOpaque sessionId.secret · secret is 32 random bytes · sha256-hashed at rest · rotates on every use
Refresh reuseA stale or forged refresh token immediately revokes that session
MFA secretAES-256-GCM encrypted at rest (MFA_ENCRYPTION_KEY), never stored plain; 8 bcrypt-hashed one-time backup codes
MFA challenge tokenA 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 keyssha256-hashed at rest; only an 12-char prefix is ever shown again after creation
WebhooksEach payload HMAC-SHA256 signed with a per-webhook secret, sent as x-dravyn-signature
Transporthelmet() security headers · CORS allowlist via CORS_ORIGINS
Tenant isolationEvery 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

ScopeLimit
Every route, default60 req / 60s per IP
register, verify-otp, login, refresh, reset-password, mfa/challenge8 req / 60s
resend-otp3 req / 60s + a separate 60s per-user cooldown
forgot-password5 req / 60s

Getting started

  1. Node 20+, pnpm (corepack enable), and a Postgres database — local via Homebrew, or free-tier Supabase for a hosted one.
  2. cd backend && cp .env.example .env
  3. node scripts/generate-keys.js — prints a fresh RS256 keypair, paste into .env.
  4. openssl rand -hex 32 — paste into MFA_ENCRYPTION_KEY.
  5. pnpm run prisma:migrate --name init && pnpm run seed — creates the schema, the default project, and the reserved platform project (prints its public key — you'll need it for the portal).
  6. pnpm run dev:api — API on :4000, docs at /api/docs.
  7. Optional: cd apps/portal && cp .env.example .env, paste the platform public key into VITE_PLATFORM_PUBLIC_KEY, then pnpm run dev — portal on :5174.
Zero-cost by design

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

RouteAuthBody / paramsReturns
POST /registeremail, password (8+), name?201 { userId } — sends OTP
POST /verify-otpemail, code (6)tokens
POST /resend-otpemail, type204
POST /loginemail, passwordtokens, or { mfaRequired: true, mfaToken }
POST /refreshrefreshTokennew tokens
POST /logoutrefreshToken204
POST /forgot-passwordemail204 always — no account-existence leak
POST /reset-passwordemail, code, newPassword204 — revokes every session
GET /meBearer{ id, projectId, email, roles, permissions }
GET /sessionsBeareractive sessions: device, IP, timestamps
DELETE /sessions/:idBearer204
GET /google · /github?publicKey=pk_…redirect to provider
GET /google/callback · /github/callbackredirect with tokens in the URL fragment

MFA — /v1/auth/mfa

RouteAuthBodyReturns
POST /setupBearer{ secret, otpauthUrl, qrCodeDataUrl } — doesn't enable MFA yet
POST /verify-setupBearercode{ backupCodes } — enables MFA, codes shown once
POST /disableBearer204
POST /challengemfaToken, codetokens — code is a TOTP or backup code

Organizations — /v1/orgs

RouteBody / paramsReturns
POST /nameorg — caller becomes OWNER
GET /orgs the caller belongs to
GET /:id/membersmembers (must belong to the org)
POST /:id/invitesemail204 — owner only, emails an invite link
POST /invites/accepttokenorg — must be logged in as the invited email
DELETE /:id/members/:userId204 — 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.

RouteReturns
GET /usersthe key's project's users
POST /users/:userId/revoke-sessions204 — 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.

RouteBody / paramsReturns
POST /projectsname{ project, secretKey }
GET /projectsprojects you own
GET /projects/:idone project
PATCH /projects/:id/brandingdisplayName?, primaryColor?updated project
GET /projects/:id/usersthat project's end users
POST /projects/:id/api-keysname, scopes?, expiresInDays?{ apiKey, rawKey }
GET /projects/:id/api-keyskeys (prefix only)
DELETE /projects/:id/api-keys/:keyId204
POST /projects/:id/webhooksurl, events[]{ webhook, secret }
GET /projects/:id/webhookswebhooks
PATCH /projects/:id/webhooks/:whIdenabled204
DELETE /projects/:id/webhooks/:whId204
GET /projects/:id/webhooks/:whId/deliverieslast 50 delivery attempts
POST /projects/:id/rolesname, permissions[]role
GET /projects/:id/rolesroles + assignee counts
PATCH /projects/:id/roles/:roleIdname?, permissions?role
DELETE /projects/:id/roles/:roleId204
POST /projects/:id/users/:userId/roles/:roleId204
DELETE /projects/:id/users/:userId/roles/:roleId204
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.

RouteBody / paramsReturns
POST /projectsname{ project, secretKey }
GET /projectsevery project + user counts
GET /projects/:id/usersusers in that project

Error shape

Every non-2xx response — validation failures, auth errors, rate limits — has the same four fields:

any 4xx/5xx response
{
  "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

Read this before running npm install

@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

any JS environment
// 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

anywhere authenticated
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

App.tsx
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:

oauth/callback 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.

Try it

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.

main.tsx
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

PieceWhereCost
APIRailway free trial credit, or any Node hostfree tier
DatabaseSupabasefree tier
Admin dashboardVercelfree tier
Developer portalVercel (separate project)free tier
EmailResend3,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-js npm 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.