- Starter
- NextJS Frontend + Rust Backend SaaS Kit
- Kit version
- 0.2.0
- Documentation version
- 0.2.0
- Covers kit version
- 0.2.0
NextJS and Rust SaaS Kit — developer documentation
This documentation describes the released behavior of the NextJS and Rust SaaS Kit: a production-ready starting point for a SaaS product, with a Next.js frontend and a Rust backend. It is written to stand on its own. A technical reader can evaluate the kit without installing it, and a developer or operator can configure, run, secure, extend, and prepare it for public exposure using this page alone.
The kit is published by StarterFoundry as a versioned product. This documentation describes kit version 0.2.0 and is itself version 0.2.0; see Version coverage for the version relationship.
Overview
What the kit provides
- Customer accounts. Registration, email verification, login, logout, password reset, breached-password screening, account lockout with owner alerting, and opaque revocable sessions with absolute and idle expiry.
- A billing foundation. A provider-agnostic
BillingProvidertrait, a mock provider whose webhooks are signed with HMAC-SHA256, and entitlement updates driven by subscription events. It is not a payment integration. - An admin surface. An admin-only page that renders account state for the signed-in administrator. Capability comes from the backend, not from the page.
- Operations. Structured logging with a JSON production format, a closed
audit.*event stream, request-ID correlation, per-IP and per-flow rate limiting, exact-origin checks, a non-root backend container image, and a container health check. - Continuous integration. Formatting, linting, unit tests, database isolation tests, a release build, supply-chain checks, a frontend build, and browser end-to-end tests.
How the pieces fit together
One repository holds two independently buildable trees. There is no root package manifest and no monorepo orchestrator coupling them.
The backend is the account authority. It owns customer records, password hashing, sessions, verification and reset tokens, rate limiting, outbound email, the billing trait, and the audit trail. Every call that carries account authority proves two separate things: the caller is the trusted frontend (a constant-time application-secret check), and the acting user is identified by an opaque session token the backend validates on every call. Callers cannot supply a user identity; identity comes only from the presented session.
The frontend is the browser-facing application. The browser talks only to the Next.js server; the Next.js server talks to the backend over the REST API using server-only environment values. The browser never receives the backend origin or the application secret. The frontend session cookie wraps the backend session token for convenience, but it is not an authorization authority: the backend re-validates the token independently on every call.
The bootstrap is a single Node program. It prepares local development: databases, environment files, migrations, and two seeded accounts. It is idempotent and never overwrites values that already exist.
Who this documentation is for
- A technical buyer assessing the kit's capabilities, security posture, operational duties, extension boundaries, and limits.
- A developer or operator starting the kit, extending it, and preparing it for public exposure on their own platform.
What the kit is not
The kit is a foundation, not a finished product. It does not include a real billing provider, multi-factor authentication, logged-in password or email change, product data models beyond the account and entitlement foundation, or a production deployment platform. The full list is under Deliberate exclusions and residual limits.
Prerequisites
Development tools
None of these is installed for you. Provide them before the first run.
| Tool | Required condition | Used for |
|---|---|---|
| Rust | 1.97.0 toolchain, managed by rustup | Building and running the backend. The pin lives in backend/rust-toolchain.toml and is honored when Cargo runs with the working directory inside backend/. The backend does not build on an older channel. |
| Node.js and npm | Node 22 with npm | The first-run bootstrap and the Next.js frontend. The frontend installs from its committed lockfile with npm ci. |
| PostgreSQL | A running Docker container named postgres_db, or a local PostgreSQL with psql and createdb on PATH | The application database and the isolation-test database. |
| openssl | On PATH | The bootstrap generates local secrets with openssl rand -hex 32. |
Customer-supplied inputs
The kit asks for these after placement. Local development starts without the first two because the bootstrap writes development placeholders, but real email delivery needs your own values.
SAAS_TEMPLATE_RESEND_API_KEYandSAAS_TEMPLATE_RESEND_FROM, inbackend/.env. Real email delivery requires your own Resend key and a verified sender. A Resend test sender (onboarding@resend.dev) delivers only to the account owner's own address, so it cannot serve real customers. In a placed project theSAAS_TEMPLATE_prefix is replaced with the prefix chosen at instantiation.- Optional:
SAAS_TEMPLATE_SEED_DEV_PASSWORDandSAAS_TEMPLATE_SEED_USER_PASSWORDpin the seeded account passwords instead of using random ones.
Never commit a real secret. .env files are git-ignored and dockerignored;
.env.example templates are committable and carry no secrets.
Platform notes
The bootstrap runs the same way on Ubuntu 24.04, native Windows 11 PowerShell,
and WSL (which uses the Ubuntu path). Native Windows does not require WSL. It
detects PostgreSQL as either a running postgres_db Docker container or local
psql/createdb tools. Git is not required for placement, bootstrap, or
local development.
First run
Run every command from the project root unless the step says otherwise.
1. Bootstrap the project
node scripts/bootstrap-dev.mjs
The bootstrap is idempotent and safe to re-run. It:
- Ensures the development database and the isolation-test database exist.
- Writes
backend/.envandfrontend/.envwith aligned values, including a shared application secret generated by openssl. Existing keys are kept and never overwritten. - Applies the database migrations.
- Seeds two verified accounts and prints their passwords when they are newly created.
If a prerequisite is missing, the bootstrap stops with a specific message
(for example error: missing openssl) before changing anything.
2. Start the backend
cd backend
cargo run
The first build compiles the workspace, which takes a few minutes. The server
then applies migrations and listens on http://127.0.0.1:3001 by default.
Confirm it is healthy:
curl http://127.0.0.1:3001/health
The response is {"status":"ok"}. Startup failures are reported as a
server.startup_failed log event with a kind of database, bind, or
serve; configuration failures are reported on stderr before logging starts.
3. Start the frontend
cd frontend
npm ci
npm run dev
The frontend is served on http://127.0.0.1:3000.
4. Verify the working loop
- Sign in as
dev@example.testwith the password from the bootstrap output. You land on/home, which renders only whatGET /v1/mereturns. - An Admin link appears for that account. Open
/admin: it shows the ADMIN banner and the account state. A non-admin is redirected to/home. - Register a fresh account through
/register. With real Resend configuration the verification email arrives; with the placeholder key the send path is exercised and failures appear asmailer.failedlog events. - Request a password reset through
/forgot-password. The page shows one message whether or not the address exists, and the reset link is single-use with a one-hour expiry.
Seeded development accounts
| Account | Role | State |
|---|---|---|
dev@example.test | Administrator and lifetime account | Verified and activated; can open /admin. |
user@example.test | Regular subscriber | Verified and activated; is redirected away from /admin. |
Passwords are random on each run and printed only when an account is created. Pin them with the seed password variables if you need stable credentials. Re-running the bootstrap never resets an existing account's password.
Common setup failures and recovery
| Symptom | Cause | Recovery |
|---|---|---|
error: missing openssl or error: missing cargo | A required tool is not on PATH. | Install the tool, then re-run the bootstrap. |
no postgres_db container and no psql/createdb | PostgreSQL is not reachable in either supported form. | Start the postgres_db container, or install PostgreSQL client tools. |
Backend exits with server.startup_failed kind=bind | The port is already in use. | Free 127.0.0.1:3001 or set SAAS_TEMPLATE_PORT (and the frontend's SAAS_TEMPLATE_BACKEND_URL to match). |
| Backend exits with a configuration error | A required variable is missing or invalid: application secret under 32 bytes, malformed frontend origin, invalid database URL, missing Resend configuration, invalid log level or format. | Read the error, correct backend/.env, and restart. Validation is fail-closed. |
401 unauthorized or 403 from the backend during normal frontend use | The two .env files disagree on SAAS_TEMPLATE_API_SECRET or SAAS_TEMPLATE_FRONTEND_URL. | Make both values identical, restart both sides, and check for stale exported shell variables: a real environment variable always beats .env. |
Edited .env has no effect | An exported shell variable overrides the file. | unset the stale variable (for example unset SAAS_TEMPLATE_API_SECRET) and restart from a fresh shell. |
| No email arrives | The placeholder Resend key exercises the send path only. | Set a real SAAS_TEMPLATE_RESEND_API_KEY and a verified SAAS_TEMPLATE_RESEND_FROM, then check mailer.failed events for request, rejected, or timeout. |
invalid database URL on startup | A non-local database URL is missing a password or sslmode=require/sslmode=verify-full. | Use a compliant URL; local passwordless URLs remain the documented dev exception. |
| Sign-in works locally but not behind a proxy | Auth.js host checks. | In production leave SAAS_TEMPLATE_AUTH_TRUST_HOST unset and set AUTH_URL to the public origin; local development sets trust host to true. |
Re-running the bootstrap
Re-running preserves your secrets, environment values, and accounts. It only creates what is missing and never overwrites what exists.
How it works
Repository layout
.
├── frontend/ Next.js 16 App Router (TypeScript, Tailwind 4, npm)
├── backend/ Rust 1.97.0 Cargo workspace (Axum, sqlx/PostgreSQL)
├── scripts/ First-run bootstrap (node scripts/bootstrap-dev.mjs)
├── docs/ Operational documentation installed with the project
├── documentation/ This published developer documentation (kit-only)
└── .github/workflows/ CI and the manual backend image publish
Each tree builds on its own. The frontend has no path dependency on the backend, and the backend has no path dependency on the frontend. The frontend reaches the product only through the backend REST API; it never touches a database directly.
Trust and authorization model
| Control | How it works |
|---|---|
| Trusted caller | The frontend server sends an application-secret header on every account call. The backend compares it in constant time. A wrong secret is 401. |
| Trusted origin | The frontend server also sends its exact configured origin. The backend rejects a request whose Origin or origin header, when present, does not match the configured frontend origin byte-for-byte (403). CORS allows only that exact origin. These checks are defense in depth, not a substitute for network isolation. |
| Acting user | The session header carries an opaque token. The backend hashes it, looks up an unrevoked, unexpired, recently active session, and reads the customer from that row. Caller-supplied user IDs are ignored. |
| Webhook caller | /v1/billing/webhook authenticates by the provider's HMAC-SHA256 signature, not by the application secret; providers call that URL directly. |
| Browser boundary | The browser holds only the Next.js session cookie. The backend origin and application secret exist only in server-side environment values. No NEXT_PUBLIC_* value exposes either. |
Backend behavior
Accounts. Email addresses are canonicalized by trimming and lowercasing.
One row exists per canonical address. Passwords must be at least 12
characters and at most 1024 bytes, must not appear on the common-password
list, and are screened against the Have I Been Pwned range API using
k-anonymity: only the first five characters of the password's SHA-1 digest
leave the process. HIBP screening fails open — if the API is unavailable the
password is accepted subject to the static policy, and a
password.pwned_check_unavailable warning is logged.
Registration. After authentication, validation, and rate limits,
registration always answers 202 and never reveals whether the address
already exists. A new address creates one unverified customer and sends a
verification email; an existing unverified customer gets a rotated
verification token; an already-verified customer is sent a neutral
"already registered" email. Passwords are hashed with argon2id once per
request.
Verification. Tokens are 64 lowercase hex characters, expire after 24 hours, and are single-use: consumption succeeds only for one concurrent caller and activates the account exactly once. Verification creates no session.
Login. Only a verified, activated account can sign in. Every attempt runs
exactly one argon2 verification, including unknown emails (against a dummy
hash), so the response does not reveal which part failed. Unknown,
unverified, not-activated, wrong-password, and locked accounts all receive
the same 401 {"error":"invalid_credentials"} and mint no session. Five
consecutive failures on an account that can actually sign in lock it for 15
minutes and send the owner a suspicious-login email. Failures while locked
neither extend nor count, and a successful login clears the counter.
Failures for unverified or not-activated accounts never count, so an attacker
cannot pre-lock a victim during onboarding.
Sessions. The raw token is 32 random bytes, hex-encoded, returned once at
login, and never stored or logged. The database stores only its SHA-256 hash.
A session expires 12 hours after login or after 30 minutes of inactivity,
whichever comes first; GET /v1/me validates and touches it. Logout revokes
the presented session only and returns 204 whether or not a row changed.
A completed password reset revokes every session for that account inside the
same transaction.
Password reset. The request route always answers 202 after
authentication and rate limits. Unknown, unverified, and malformed addresses
are shaded: no email and no observable difference beyond normal latency.
Verified accounts receive a message containing a reset link with a 64-hex
token that expires after one hour and is single-use. The password policy is
checked before the token is consumed, so a policy failure leaves the token
usable. A successful reset replaces the password hash and revokes all
sessions in one transaction. No reset route mints a session.
Rate limiting. All limits use a 15-minute sliding window and are enforced in memory per process. The per-IP bucket is keyed by the socket peer address, never by a forwarded header. See Rate limits for the full table.
Billing. The application depends on the BillingProvider trait, not a
vendor SDK. The default mock provider signs payload bodies with HMAC-SHA256
and verifies signatures in constant time. subscription.activated sets the
activation time and next billing date, subscription.cancelled clears both,
and payment.failed is advisory with no entitlement change in this kit.
Events for unknown customers are a no-op success: webhook providers retry,
and the endpoint must not reveal customer existence or fail on out-of-order
delivery.
Email. Verification, already-registered, password-reset, and
suspicious-login messages are sent through Resend over HTTPS with rustls.
Text and HTML bodies are both produced; interpolated values are escaped.
Responses are padded to a 750 ms anti-enumeration budget and network calls
are cancelled after 2 seconds, so known and unknown addresses look alike to
the caller. Mailer failures are logged as mailer.failed and never returned
to the caller.
Frontend behavior
The App Router lives at frontend/src/app/ with the import alias @/*
mapping to frontend/src/*.
| Route | Behavior |
|---|---|
/ | Pre-publication landing page rendered from static kit copy. |
/register | Collects email and password, submits through a server action, and shows identical success copy whether or not the address exists. |
/verify?token= | Shape-checks the token on GET and never consumes it during render. The customer confirms through a single-flight control; the token is stripped from the URL after success. |
/login | Collects email and password; on success the backend token is stored only in the Auth.js encrypted JWT cookie. A dead cookie is cleared through a server action. |
/home | Protected Server Component. Renders only the GET /v1/me fields. A failed call renders no account data and clears the client session. |
/forgot-password | One non-enumerating response; the raw reset token is never part of the page. |
/reset?token= | Shape-checks the token on GET, never consumes it during render, and submits the new password through a server action. Policy failures are retryable on the same token. |
/admin | Requires admin: true from /v1/me; non-admins are redirected to /home. |
/sample-page | Clearly labelled illustrative detail-page pattern; not product content. |
/api/auth/[...nextauth] | The only API route; Auth.js session handling. The browser talks only to Next.js. |
Server actions and a server-only backend client are the only path to the
backend. Middleware protects /home and /admin; unauthenticated requests
are redirected to /login. Cookie writes happen only in server actions and
the Auth.js route handler, never during Server Component render. The
landing and sample copy in frontend/src/lib/landing-content.ts is static
kit content, not release-ready product copy.
Configuration model
The backend reads non-secret defaults from config/default.toml and
everything else from prefixed environment variables. Local development
auto-loads backend/.env; a real environment variable always wins over the
file, and a missing file is not an error (containers supply variables
directly). Only the documented prefix is read — prefix-less names such as
RUST_LOG or DATABASE_URL have no effect.
The frontend reads server-only SAAS_TEMPLATE_* values. backend-client.ts
throws if a required value is missing when used, and auth.ts refuses to
start if SAAS_TEMPLATE_AUTH_SECRET is missing or under 32 bytes. Never
introduce a NEXT_PUBLIC_* value for the backend origin or any secret.
In a placed project every SAAS_TEMPLATE_ name appears under the prefix
chosen at instantiation. This documentation describes the kit source, which
uses SAAS_TEMPLATE_.
Data and migrations
Four versioned SQL migrations are embedded in the backend binary and applied automatically at startup (and by the seed command):
0001_customer_accounts.sql—customersandsessions.0002_email_verification_tokens.sql— single-use expiring verification tokens.0003_password_reset_tokens.sql— single-use one-hour reset tokens.0004_failed_login_lockout.sql— failed-login counter and lock window oncustomers.
Token tables store SHA-256 hashes only, sessions store token hashes only, and passwords are stored as argon2id PHC strings. No raw token or password is stored at rest.
Observability and the audit trail
Logging writes to stdout, one JSON object per line in production (json) or
readable lines locally (pretty). The event catalog is closed:
- Server lifecycle:
server.config_loaded,server.listening,server.startup_failed(kind:database/bind/serve),server.shutdown. - HTTP:
http.requestwithmethod, matched route template (orunmatched),status,duration_ms, andrequest_id. Raw URIs, query strings, headers, and bodies are never logged. - Email and security:
mailer.failed(kindandrequest/rejected/timeout),password.pwned_rejected,password.pwned_check_unavailable,account.locked. - Audit stream (INFO):
audit.login_success,audit.login_failure(reason),audit.register(outcome),audit.verify,audit.password_reset_requested,audit.password_reset_consumed,audit.logout,audit.billing_event(event kind). Audit events attribute by customer UUID only — never emails, tokens, passwords, or IPs.
Every HTTP request carries a request_id: a caller-supplied X-Request-Id
when it passes a strict sanitizer (alphanumerics plus -_., at most 128
characters), otherwise a generated UUID. The value is echoed on the response
so frontend and backend logs can be correlated.
The kit does not provide metrics, distributed tracing, log shipping, rotation, or frontend logging. The audit stream is designed to be shipped to a log collector; operating that pipeline is the operator's job.
CI and release surfaces
.github/workflows/ci.yml runs on push and pull request to main:
| Job | What it proves |
|---|---|
| Backend | cargo fmt --all --check, clippy with -D warnings, workspace tests on Rust 1.97.0, and a release build of the server. |
| Security | cargo audit and cargo deny check. |
| Frontend | npx audit-ci@^7 at high severity (one time-boxed exception, documented in frontend/audit-ci.jsonc), npm ci, lint, Vitest, production build, and Playwright end-to-end tests on chromium. |
.github/workflows/publish-backend.yml is manual (workflow_dispatch). It
builds backend/Dockerfile, smoke-tests the image against a CI PostgreSQL
service (boots, migrates, becomes healthy, runs as uid 10001, serves
/health), then pushes sha-<commit> and latest tags to
ghcr.io/YOUR_ORG/saas-template-backend. The image is deliberately not
built on every push, so the per-push battery stays fast. Publishing is a
gated, manual action, not an automatic release.
Supported extension
The intended extension paths are below. Each one preserves the product boundary: the frontend reaches the product only through the backend REST API, and the backend authorizes every call independently.
Add a real billing provider
- Implement the
BillingProvidertrait:verify_webhook,parse_event, andcreate_subscription. - Register it where the mock provider is constructed
(
AppState::with_mailer_and_budgetinbackend/.../src/http.rs) in place ofMockBillingProvider. - Point the provider's webhook at
/v1/billing/webhookand configure the provider's signing secret asSAAS_TEMPLATE_BILLING_WEBHOOK_SECRET. - Keep the entitlement mapping in
billing::apply_eventas the single place that changes subscription state, and keep unknown customers a no-op.
The webhook route's authentication is the provider signature, not the application secret; providers call the URL directly.
Grow the product data model and API
Add migrations to backend/migrations/ and register them in the embedded
migration list (backend/.../src/db.rs). Add routes beside the existing ones
and keep two rules: acting-user identity comes only from the validated
session, and the frontend reaches new data only through new backend routes.
New endpoints need the same trusted-caller and origin checks as the existing
surface unless they are provider webhooks with their own signature
authentication.
Change product identity and copy
Canonical identity lives in backend/.../src/brand.rs and
frontend/src/lib/brand.ts: display name, protocol header names, and cookie
slug. Structural strings derive from those modules, so a rename is
mechanical. The kit.json display-name token must stay byte-equal to the
display name in both brand modules; instantiation rewrites them together.
frontend/src/lib/landing-content.ts holds the landing and sample copy;
rewrite it for your product and keep the export names the tests use.
Extend the product UI
Add pages and components under frontend/src/app/. Protect non-public routes
by extending the middleware matcher, and keep cookie writes inside server
actions or route handlers. Client components must never receive the backend
origin, the application secret, or the raw backend token.
Enable real email delivery
Replace SAAS_TEMPLATE_RESEND_API_KEY and SAAS_TEMPLATE_RESEND_FROM with a
real key and an address on a verified Resend domain. Use a Sending-access
key, not Full access. Until then, the placeholder key exercises only the
send path and the test sender delivers only to the account owner's address.
Prepare for production
Work through the provider-neutral operating contract before exposing anything publicly. It lists the conditions the deployment must satisfy and the controls the operator owns.
Verify an extension
A change is trusted only after the repository's own battery passes. Run the focused checks for what you changed, then the full set before you push; CI runs the same commands.
# Backend (from backend/)
cargo fmt --all --check
cargo clippy --locked --workspace --all-targets -- -D warnings
cargo test --locked --workspace --all-targets
cargo build --locked --release -p saas-template-server
cargo audit
cargo deny check
# Frontend (from frontend/)
npm ci
npx audit-ci@^7 --config ./audit-ci.jsonc
npm run lint
npm run test
npm run build
npm run test:e2e
cargo test needs SAAS_TEMPLATE_TEST_DATABASE_URL for the isolation tests;
the bootstrap creates that database. npm run test:e2e needs chromium
installed (npx playwright install chromium).
Reference and limits
Environment variables (backend)
All values are server-side only. Required variables have no default; the backend refuses to start when one is missing or invalid.
| Variable | Required | Default | Notes |
|---|---|---|---|
SAAS_TEMPLATE_DATABASE_URL | Yes | postgres://localhost/saas_template from config/default.toml | Non-local URLs must carry a non-empty password and sslmode=require or sslmode=verify-full. Local (localhost, loopback, socket) URLs may be passwordless. |
SAAS_TEMPLATE_HOST | Yes | 127.0.0.1 from config/default.toml | Bind address. Keep it non-public. |
SAAS_TEMPLATE_PORT | Yes | 3001 from config/default.toml | Bind port; 0 is invalid. |
SAAS_TEMPLATE_API_SECRET | Yes | — | Trusted-caller secret. At least 32 bytes. Must match the frontend value byte-for-byte. |
SAAS_TEMPLATE_FRONTEND_URL | Yes | — | Exact origin: scheme, host, optional port, no path, credentials, query, or fragment. Drives CORS and the origin check. Must match the frontend value. |
SAAS_TEMPLATE_LOG_LEVEL | Yes | info from config/default.toml | One of error, warn, info, debug, trace. Other names are rejected; RUST_LOG is ignored. |
SAAS_TEMPLATE_LOG_FORMAT | Yes | pretty from config/default.toml | json or pretty. The production image sets json. |
SAAS_TEMPLATE_RESEND_API_KEY | Yes | — | At least 20 bytes. Missing or short configuration fails startup. |
SAAS_TEMPLATE_RESEND_FROM | Yes | — | A local@domain mailbox; whitespace and extra @ are rejected. |
SAAS_TEMPLATE_BILLING_WEBHOOK_SECRET | Yes | — | At least 32 bytes. Signs and verifies billing webhooks. |
SAAS_TEMPLATE_SEED_DEV_PASSWORD, SAAS_TEMPLATE_SEED_USER_PASSWORD | No | Random per run | Used only by cargo run -- seed. |
Pool sizing (pool_max_connections, pool_connect_timeout_secs in
config/default.toml) is file-configured only; environment variables do not
override it.
Environment variables (frontend)
All values are server-only. Never use a NEXT_PUBLIC_ prefix for any of
them.
| Variable | Required | Notes |
|---|---|---|
SAAS_TEMPLATE_BACKEND_URL | Yes | Backend origin used by the server-only client. Must match where the backend listens. |
SAAS_TEMPLATE_API_SECRET | Yes | Must match the backend value byte-for-byte. |
SAAS_TEMPLATE_FRONTEND_URL | Yes | The public origin sent as the trusted origin header; must match the backend value. Becomes the https:// public origin in production. |
SAAS_TEMPLATE_AUTH_SECRET | Yes | Auth.js JWT encryption secret; at least 32 bytes; fails closed when missing or short. |
SAAS_TEMPLATE_AUTH_TRUST_HOST | No | Only the literal true enables Host-header trust. Use for local development, WSL, or a trusted proxy; leave unset in production and set AUTH_URL to the public origin instead. |
HTTP surface
All routes except /health and /v1/billing/webhook require the
application-secret header and matching origin headers. GET/POST only.
| Route | Request | Success | Errors |
|---|---|---|---|
GET /health | — | 200 {"status":"ok"}. No secret required. | — |
GET /v1/me | Session header | 200 with id, email, email_verified, subscriber_active, subscription_started_at, next_billing_at, lifetime, admin. | 401 when the secret, session, or account is invalid. |
POST /v1/login | {email, password} | 200 {"token","expires_at"} once. | 401 {"error":"invalid_credentials"} for every failure; 429 when rate limited. |
POST /v1/logout | Session header | 204, idempotent. | 401 when the caller checks fail. |
POST /v1/register | {email, password} | 202 {"status":"accepted"}. | 400 {"error":"invalid_request"} for policy or shape failures; 429. |
POST /v1/verify | {token} | 200 {"status":"verified"}. | 400 {"error":"invalid_token"}; 429. |
POST /v1/verification/resend | {email} | 202 {"status":"accepted"}. | 400 {"error":"invalid_request"}; 429. |
POST /v1/password/reset/request | {email} | 202 {"status":"accepted"}. | 400 {"error":"invalid_request"}; 429. |
POST /v1/password/reset | {token, password} | 200 {"status":"reset"}. | 400 {"error":"invalid_token"} or {"error":"invalid_request"}; 429. |
POST /v1/billing/webhook | Provider payload + X-Billing-Webhook-Signature | 200 with an empty body. | 401 on signature failure; 400 on malformed payload. |
Unknown JSON fields are rejected. Unauthenticated or origin-rejected calls
return 401 {"error":"unauthorized"} or a 403 with no body. Internal
failures return 500 {"error":"internal_server_error"} without internal
detail.
Sessions, tokens, and lockout
| Item | Value |
|---|---|
| Session token | 32 random bytes, hex-encoded; returned once; stored as SHA-256 only. |
| Session lifetime | 12 hours absolute, 30 minutes inactivity, whichever comes first. |
| Verification token | 64 lowercase hex; single-use; 24-hour expiry. |
| Password-reset token | 64 lowercase hex; single-use; 1-hour expiry. |
| Password policy | At least 12 characters, at most 1024 bytes, not on the common list, not found in the HIBP corpus. |
| Password hashing | argon2id, 64 MiB memory, 3 iterations, 1 lane. |
| Login lockout | 5 consecutive failures on a loginable account lock it for 15 minutes; success clears the counter. |
Rate limits
All buckets share a 15-minute sliding window and are per process.
| Scope | Keyed by | Limit |
|---|---|---|
| Per peer | Socket peer IP | 120 requests |
| Register and verification resend | Canonical email | 5 per email, 60 global |
| Verification token | Token hash | 10 |
| Login | Login email key | 10 per email, 60 global |
| Password-reset request | Reset email key | 5 per email, 60 global |
| Password-reset consume | Token hash | 10 |
Provider-neutral operating contract
This contract applies to any platform. It states what must be true before the product is publicly reachable. The kit enforces the application-level items; the operator owns everything else.
What the kit enforces
- Server-side secrets only; no secret or backend origin reaches the browser.
- Constant-time application-secret checks and exact-origin checks on every account route.
- Independent per-call authorization from the presented session.
- Hashed token storage, single-use expiring tokens, non-enumerating responses, lockout, and rate limiting.
- Fail-closed configuration: the backend refuses to start with a missing or
invalid required value, and the frontend refuses to start without a
sufficiently long
SAAS_TEMPLATE_AUTH_SECRET. - Non-local database URLs require a password and TLS (
sslmode=requireorsslmode=verify-full). - Outbound calls (Resend) use TLS with native roots.
- A non-root container image with a health check.
Trust boundaries and network isolation
- The backend is never publicly reachable. Bind it to loopback or an internal network, and accept traffic only from the frontend server and operations tooling. The application secret and origin checks are defense in depth, not a substitute for isolation.
- The frontend is the only public application endpoint. It may be reached through the edge proxy; it talks to the backend over the private network.
- The database is reachable only from the backend. Do not expose the PostgreSQL port publicly.
- Client IP at the edge. Backend per-IP limiting uses the socket peer address, which is the proxy's address when a proxy sits in front. Apply end-user per-IP limiting at the edge, where the real client address is known and the forwarded header is set by a trusted hop.
TLS termination
Terminate TLS at the reverse proxy or load balancer. The browser sees HTTPS
end to end; the proxy speaks plain HTTP to the applications over a private
network. Do not add inbound TLS to the Rust or Next.js processes, and do not
commit certificates into the application trees. Forward the original Host
(or set X-Forwarded-Host) consistently so Auth.js host checks and origin
checks see the public name. In production, set SAAS_TEMPLATE_FRONTEND_URL
to the public https:// origin on both sides (byte-identical) and set
AUTH_URL to the same origin with host trust disabled (leave
SAAS_TEMPLATE_AUTH_TRUST_HOST unset or false).
Secrets
-
Generate every secret with
openssl rand -hex 32(or an equivalent cryptographic generator) and supply it through the environment or a secret manager. Never commit secrets;.envfiles are local only and should bechmod 600. -
Rotate the application secret by updating both sides together and restarting both services; there is no zero-downtime rotation path in the kit.
-
Rotating the Auth.js secret invalidates every browser session cookie. Rotating the application secret should be paired with a session revoke when leakage is suspected. The kit has no global-revoke endpoint, so do it as a database step. Revoke every active session:
UPDATE sessions SET revoked_at = NOW() WHERE revoked_at IS NULL;Or scope the revoke to one account:
UPDATE sessions SET revoked_at = NOW() WHERE revoked_at IS NULL AND customer_id = '<customer-uuid>'; -
Scope the Resend key to Sending access. Keep the billing webhook secret synchronized with the provider configuration.
-
The database password lives only in
SAAS_TEMPLATE_DATABASE_URLon the backend side and is not part of the application secret set.
Database protection
- Use a dedicated database user with rights limited to the application schema; the application runs migrations at startup, so it needs DDL rights on that schema.
- For anything non-local, require TLS with a non-empty password; the kit refuses to start otherwise.
- Own backups, point-in-time recovery, restore testing, encryption at rest, and access control. The kit ships no backup tooling.
Outbound email
- Use a verified Resend domain and a Sending-access key. Set
SAAS_TEMPLATE_RESEND_FROMto an address on the verified domain. - The development placeholder key and the test sender are not production
delivery. Expect
mailer.failedevents when delivery is misconfigured; alert on them.
Billing integration
- A real provider delivers signed webhooks directly to
/v1/billing/webhook; that path must be reachable by the provider while the backend stays otherwise private. - Configure the provider's signing secret to match
SAAS_TEMPLATE_BILLING_WEBHOOK_SECRET, and validate signature behavior in the provider's test mode before enabling live events. - The endpoint treats unknown customers as a no-op success and
payment.failedas advisory; build dunning or notification behavior in your provider or application if you need it.
Observability and operations
- Ship stdout JSON logs to your collector and set retention. The audit
stream (
audit.*) is the security-relevant record; attribute alerts onaccount.lockedandmailer.failed. - Keep request IDs end to end: the frontend generates
X-Request-Id, the backend echoes it and logs it. Include it in support and incident review. - The kit provides no metrics, tracing export, dashboards, or paging. Add them at the platform level if the product needs them.
Pre-exposure checklist
- Backend bound to a private interface; database port not public.
- TLS terminating at the proxy with a real certificate; HTTP redirects to HTTPS.
SAAS_TEMPLATE_FRONTEND_URLis the publichttps://origin on both sides;AUTH_URLmatches; host trust is off.- All production secrets in place and stored outside the repository: the application secret, Auth.js secret, and billing webhook secret generated for production, and a production Resend key issued to a Sending-access key. None reuse development values.
- Database user scoped, TLS required for its connection URL, backups and restore testing in place.
- Resend domain verified and
SAAS_TEMPLATE_RESEND_FROMset to an address on it. - Billing provider configured with the deployed webhook URL and matching signing secret.
- Log shipping enabled, retention set, and alerts configured on
account.lockedandmailer.failed. - The full repository verification battery passes on the deployed revision.
- Landing and sample copy rewritten for the product; no kit placeholder copy remains public.
Deliberate exclusions and residual limits
- No real billing provider. The mock provider is development and test infrastructure, not a payment integration.
- No multi-factor authentication, logged-in password change, or email change. Password recovery is the only credential-recovery path.
- No global session-revoke endpoint. Revoking all sessions for an account is a database step (or is triggered by a completed password reset, which revokes that account's sessions).
- In-memory rate limiting. Buckets are per process and reset on restart; a multi-instance deployment would need a shared limiter (the trait exists for that, but no shared implementation ships).
- HIBP screening fails open. An unavailable range API degrades to the static policy and logs a warning.
- CSP uses
'unsafe-inline'for scripts and styles because Next.js hydration needs it; upgrading to a nonce pipeline is the documented hardening step. - Verify-page query reflection. Next.js may reflect the request's own query string into framework-generated HTML for the dynamic verify route. This adds no exposure beyond the URL itself (the browser, TLS endpoint, and server access logs already see the token in the request line); no application-controlled serialization exists.
- No metrics, tracing export, log files, rotation, or frontend logging.
- The landing page and sample page are static kit copy, not release-ready product content.
- Catalogue browsing and product data models beyond accounts and entitlements are not implemented.
- TLS, network isolation, backups, secret storage, and log retention are operator responsibilities, as stated in the operating contract.
Version coverage
Documentation version 0.2.0. It covers kit version 0.2.0.
This documentation describes released behavior only and never presents
planned capability as available. The publication is versioned and published
separately from the kit; a placed project carries its own installed guidance
in AGENTS.md and docs/. Improvements to this documentation may be
published independently of a kit release, but they never document unreleased
behavior.