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 BillingProvider trait, 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.

ToolRequired conditionUsed for
Rust1.97.0 toolchain, managed by rustupBuilding 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 npmNode 22 with npmThe first-run bootstrap and the Next.js frontend. The frontend installs from its committed lockfile with npm ci.
PostgreSQLA running Docker container named postgres_db, or a local PostgreSQL with psql and createdb on PATHThe application database and the isolation-test database.
opensslOn PATHThe 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_KEY and SAAS_TEMPLATE_RESEND_FROM, in backend/.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 the SAAS_TEMPLATE_ prefix is replaced with the prefix chosen at instantiation.
  • Optional: SAAS_TEMPLATE_SEED_DEV_PASSWORD and SAAS_TEMPLATE_SEED_USER_PASSWORD pin 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:

  1. Ensures the development database and the isolation-test database exist.
  2. Writes backend/.env and frontend/.env with aligned values, including a shared application secret generated by openssl. Existing keys are kept and never overwritten.
  3. Applies the database migrations.
  4. 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

  1. Sign in as dev@example.test with the password from the bootstrap output. You land on /home, which renders only what GET /v1/me returns.
  2. 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.
  3. 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 as mailer.failed log events.
  4. 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

AccountRoleState
dev@example.testAdministrator and lifetime accountVerified and activated; can open /admin.
user@example.testRegular subscriberVerified 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

SymptomCauseRecovery
error: missing openssl or error: missing cargoA required tool is not on PATH.Install the tool, then re-run the bootstrap.
no postgres_db container and no psql/createdbPostgreSQL is not reachable in either supported form.Start the postgres_db container, or install PostgreSQL client tools.
Backend exits with server.startup_failed kind=bindThe 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 errorA 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 useThe 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 effectAn 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 arrivesThe 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 startupA 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 proxyAuth.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

ControlHow it works
Trusted callerThe frontend server sends an application-secret header on every account call. The backend compares it in constant time. A wrong secret is 401.
Trusted originThe 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 userThe 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 boundaryThe 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/*.

RouteBehavior
/Pre-publication landing page rendered from static kit copy.
/registerCollects 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.
/loginCollects 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.
/homeProtected Server Component. Renders only the GET /v1/me fields. A failed call renders no account data and clears the client session.
/forgot-passwordOne 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.
/adminRequires admin: true from /v1/me; non-admins are redirected to /home.
/sample-pageClearly 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):

  1. 0001_customer_accounts.sql — customers and sessions.
  2. 0002_email_verification_tokens.sql — single-use expiring verification tokens.
  3. 0003_password_reset_tokens.sql — single-use one-hour reset tokens.
  4. 0004_failed_login_lockout.sql — failed-login counter and lock window on customers.

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.request with method, matched route template (or unmatched), status, duration_ms, and request_id. Raw URIs, query strings, headers, and bodies are never logged.
  • Email and security: mailer.failed (kind and request/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:

JobWhat it proves
Backendcargo fmt --all --check, clippy with -D warnings, workspace tests on Rust 1.97.0, and a release build of the server.
Securitycargo audit and cargo deny check.
Frontendnpx 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

  1. Implement the BillingProvider trait: verify_webhook, parse_event, and create_subscription.
  2. Register it where the mock provider is constructed (AppState::with_mailer_and_budget in backend/.../src/http.rs) in place of MockBillingProvider.
  3. Point the provider's webhook at /v1/billing/webhook and configure the provider's signing secret as SAAS_TEMPLATE_BILLING_WEBHOOK_SECRET.
  4. Keep the entitlement mapping in billing::apply_event as 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.

VariableRequiredDefaultNotes
SAAS_TEMPLATE_DATABASE_URLYespostgres://localhost/saas_template from config/default.tomlNon-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_HOSTYes127.0.0.1 from config/default.tomlBind address. Keep it non-public.
SAAS_TEMPLATE_PORTYes3001 from config/default.tomlBind port; 0 is invalid.
SAAS_TEMPLATE_API_SECRETYes—Trusted-caller secret. At least 32 bytes. Must match the frontend value byte-for-byte.
SAAS_TEMPLATE_FRONTEND_URLYes—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_LEVELYesinfo from config/default.tomlOne of error, warn, info, debug, trace. Other names are rejected; RUST_LOG is ignored.
SAAS_TEMPLATE_LOG_FORMATYespretty from config/default.tomljson or pretty. The production image sets json.
SAAS_TEMPLATE_RESEND_API_KEYYes—At least 20 bytes. Missing or short configuration fails startup.
SAAS_TEMPLATE_RESEND_FROMYes—A local@domain mailbox; whitespace and extra @ are rejected.
SAAS_TEMPLATE_BILLING_WEBHOOK_SECRETYes—At least 32 bytes. Signs and verifies billing webhooks.
SAAS_TEMPLATE_SEED_DEV_PASSWORD, SAAS_TEMPLATE_SEED_USER_PASSWORDNoRandom per runUsed 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.

VariableRequiredNotes
SAAS_TEMPLATE_BACKEND_URLYesBackend origin used by the server-only client. Must match where the backend listens.
SAAS_TEMPLATE_API_SECRETYesMust match the backend value byte-for-byte.
SAAS_TEMPLATE_FRONTEND_URLYesThe public origin sent as the trusted origin header; must match the backend value. Becomes the https:// public origin in production.
SAAS_TEMPLATE_AUTH_SECRETYesAuth.js JWT encryption secret; at least 32 bytes; fails closed when missing or short.
SAAS_TEMPLATE_AUTH_TRUST_HOSTNoOnly 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.

RouteRequestSuccessErrors
GET /health—200 {"status":"ok"}. No secret required.—
GET /v1/meSession header200 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/logoutSession header204, 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/webhookProvider payload + X-Billing-Webhook-Signature200 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

ItemValue
Session token32 random bytes, hex-encoded; returned once; stored as SHA-256 only.
Session lifetime12 hours absolute, 30 minutes inactivity, whichever comes first.
Verification token64 lowercase hex; single-use; 24-hour expiry.
Password-reset token64 lowercase hex; single-use; 1-hour expiry.
Password policyAt least 12 characters, at most 1024 bytes, not on the common list, not found in the HIBP corpus.
Password hashingargon2id, 64 MiB memory, 3 iterations, 1 lane.
Login lockout5 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.

ScopeKeyed byLimit
Per peerSocket peer IP120 requests
Register and verification resendCanonical email5 per email, 60 global
Verification tokenToken hash10
LoginLogin email key10 per email, 60 global
Password-reset requestReset email key5 per email, 60 global
Password-reset consumeToken hash10

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=require or sslmode=verify-full).
  • Outbound calls (Resend) use TLS with native roots.
  • A non-root container image with a health check.

Trust boundaries and network isolation

  1. 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.
  2. 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.
  3. The database is reachable only from the backend. Do not expose the PostgreSQL port publicly.
  4. 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; .env files are local only and should be chmod 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_URL on 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_FROM to an address on the verified domain.
  • The development placeholder key and the test sender are not production delivery. Expect mailer.failed events 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.failed as 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 on account.locked and mailer.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

  1. Backend bound to a private interface; database port not public.
  2. TLS terminating at the proxy with a real certificate; HTTP redirects to HTTPS.
  3. SAAS_TEMPLATE_FRONTEND_URL is the public https:// origin on both sides; AUTH_URL matches; host trust is off.
  4. 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.
  5. Database user scoped, TLS required for its connection URL, backups and restore testing in place.
  6. Resend domain verified and SAAS_TEMPLATE_RESEND_FROM set to an address on it.
  7. Billing provider configured with the deployed webhook URL and matching signing secret.
  8. Log shipping enabled, retention set, and alerts configured on account.locked and mailer.failed.
  9. The full repository verification battery passes on the deployed revision.
  10. 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.