A reusable fullstack template for platforms with secure defaults and clear service boundaries.
This template provides:
- React SPA frontend behind nginx edge
- Node/Express backend split into domain services (
auth-service,core-api,tools-service,reports-service) - PostgreSQL persistence with startup migrations
- Redis-backed distributed rate limiting
- JWT + rotating refresh sessions with replay/reuse protections
- Optional DPoP token binding with nonce and replay enforcement
- Optional TOTP MFA with enrollment and challenge flows
- RBAC, audit logging, and health endpoints
- TLS 1.3-only edge configuration with strong ciphersuites
Use this template when you need to build a production-grade platform quickly while keeping security controls close to the defaults.
Primary goals:
- Strong authentication and session lifecycle controls
- Route-level authorization using backend-enforced RBAC
- Isolation of concerns through service boundaries
- Observable runtime behavior (health + audit)
- A practical extension path for future domain modules
flowchart LR
B[Browser SPA] -->|HTTPS| W[Edge nginx]
W -->|/api/auth, /api/profile, /api/audit, /api/settings| A[auth-service]
W -->|/api/tools| T[tools-service]
W -->|/api/reports| R[reports-service]
W -->|other /api routes| C[core-api]
A --> P[(PostgreSQL)]
T --> P
R --> P
C --> P
A --> X[(Redis)]
auth-service- Authentication, refresh rotation, logout
- DPoP validation and nonce challenge flow
- MFA enrollment and challenge completion
- Profile APIs, settings read APIs, audit read APIs
- Force logout and token revocation actions
core-api- Core platform APIs and aggregate component health scaffold
tools-service- Utility/tooling endpoints scaffold
reports-service- Reporting endpoints scaffold
edge (nginx)- TLS termination and route dispatch
/api/auth/*,/api/profile/*,/api/audit/*,/api/settings/*->auth-service/api/tools/*->tools-service/api/reports/*->reports-service- Remaining
/api/*->core-api /-> React client
- Frontend: React + Vite, static content served by nginx
- Backend: Node.js 20 + Express + TypeScript
- Database: PostgreSQL 16
- Cache/rate-limits: Redis 7
- Edge proxy: nginx 1.27
- Container orchestration: Docker Compose
.
├─ client/
│ ├─ src/
│ │ ├─ App.tsx
│ │ └─ lib/dpop.ts
│ ├─ Dockerfile
│ └─ nginx.conf
├─ deploy/
│ ├─ certs/
│ └─ nginx.conf
├─ server/
│ ├─ src/
│ │ ├─ auth-service.ts
│ │ ├─ core-api.ts
│ │ ├─ tools-service.ts
│ │ ├─ reports-service.ts
│ │ ├─ db.ts
│ │ ├─ middleware/
│ │ ├─ routes/
│ │ ├─ services/
│ │ └─ utils/
│ ├─ Dockerfile
│ └─ package.json
├─ docker-compose.yml
├─ .env.example
└─ README.md
- Docker Desktop (with Compose v2)
- OpenSSL (for local certificate generation)
- Optional for local non-container development:
- Node.js 20+
- npm
cp .env.example .envFor local dev, defaults work. For any shared/dev server, replace at minimum:
JWT_SECRETPSS_MASTER_KEYPGPASSWORD
mkdir -p deploy/certs
openssl req -x509 -newkey rsa:4096 -sha256 -days 365 -nodes \
-keyout deploy/certs/tls.key \
-out deploy/certs/tls.crt \
-subj "/CN=localhost"docker compose up --build- App:
https://localhost - API base via edge:
https://localhost/api - HTTP (
http://localhost) redirects to HTTPS
Created automatically when NODE_ENV != production:
- Username:
admin - Password:
ChangeMe!123 - Role:
hr_admin
You can run services directly, but you still need PostgreSQL and Redis available.
From server/:
npm install
npm run build
npm run dev:auth
npm run dev:core
npm run dev:tools
npm run dev:reportsFrom client/:
npm install
npm run devNotes:
- Set
VITE_API_BASEto your API origin if not using default/api. - Cookie and DPoP flows are simplest when requests originate from HTTPS and same site through nginx.
Defined in .env.example.
JWT_SECRET: HMAC signing key for access tokensPSS_MASTER_KEY: master key material for AES-GCM encryption of secrets-at-rest
PGHOST,PGPORT,PGUSER,PGPASSWORD,PGDATABASE
REDIS_ENABLED(true/false)REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,REDIS_DBREDIS_KEY_PREFIX
JWT_TTL_SECONDSREFRESH_TOKEN_TTL_SECONDSJWT_REFRESH_WINDOW_SECONDS(currently configuration exposure; not yet actively enforced in refresh logic)AUTH_MAX_FAILED_LOGINSAUTH_AUTO_UNLOCK_MINUTES
MFA_TOTP_ISSUERMFA_TOTP_PERIOD_SECONDSMFA_ENROLL_TTL_SECONDSMFA_CHALLENGE_TTL_SECONDS
DPOP_NONCE_TTL_SECONDSDPOP_PROOF_MAX_AGE_SECONDS
AUTH_SERVICE_PORT(default3001)CORE_API_PORT(default3002)TOOLS_SERVICE_PORT(default3004)REPORTS_SERVICE_PORT(default3005)
edge: public ingress-facing network for nginx and app exposurebackend(internal: true): private network for service-to-service traffic
pg_data: persistent PostgreSQL data
auth-servicewaits forpostgresandredishealth checkscore-api,tools-service,reports-servicewait forpostgresedgedepends on app/service containers
Edge config (deploy/nginx.conf) enforces:
- TLS 1.3 only (
ssl_protocols TLSv1.3;) - Ciphersuite allowlist:
TLS_AES_256_GCM_SHA384TLS_CHACHA20_POLY1305_SHA256TLS_AES_128_GCM_SHA256
- HSTS enabled with preload directive
X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: strict-origin-when-cross-origin- Session tickets disabled
- HTTP to HTTPS redirect
Each service boots through shared helpers and gets:
helmethardening- CORS with credentials enabled
- JSON body parsing with size limits
- Request logging (
morgan) - Standard
/api/health - Common not-found and error handlers
- Graceful shutdown on
SIGTERM/SIGINT
Startup path:
- Run DB migrations (
migrate()) - Initialize app and routes
- Start listener
- On shutdown, close PostgreSQL and Redis connections
Migrations are in server/src/db.ts and are designed to be idempotent.
Key points:
- Uses
CREATE TABLE IF NOT EXISTS, additiveALTER TABLE, and safeUPDATEbackfills - Uses PostgreSQL advisory lock (
pg_advisory_lock) to prevent concurrent migration races - Seeds roles on startup
- Seeds a non-production admin user
rolesusersauth_sessionsauth_mfa_challengesuser_profilesuser_preferencesaudit_logsdpop_replay_cachedpop_nonces
hr_adminmanageremployee
JWT (HS256) claims include:
sub,username,role,sid,tv,jti,iat,exp- Optional
cnf.jktfor DPoP-bound sessions
- Stored server-side as SHA-256 hash in
auth_sessions - Client receives refresh token in
HttpOnlycookie (refresh_token) - Token rotates on every refresh
- Refresh cookie:
HttpOnly,Secure,SameSite=Strict, path/api/auth
- CSRF cookie (
csrf_token):Secure,SameSite=Strict, JS-readable
- Refresh endpoint requires matching
X-CSRF-Tokenheader (double-submit pattern)
If refresh token hash does not match active server record for same sid:
- Session is treated as replay/reuse
- Entire session family is revoked
- User
token_versionincrements (force invalidation of active access JWTs) - Refresh fails with
401
- Permanent status:
is_active - Temporary lock:
failed_login_attempts+login_locked_until - Failed password attempts increment counters and can trigger lock
- Successful login resets counters
Admin route can revoke all sessions for a user and increment token version.
This template supports both bearer and DPoP-bound sessions.
Behavior:
- If
dpop_jwkis provided at login/MFA completion:- Session stores
dpop_jktthumbprint - Access token includes
cnf.jkt - Protected calls must use:
Authorization: DPoP <access_token>DPoP: <proof_jwt>
- Session stores
- If session is not DPoP-bound:
- Standard
Authorization: Bearer <access_token>accepted
- Standard
Validation checks for bound sessions:
- Proof signature and JWK integrity
typ = dpop+jwt- Allowed asymmetric algorithms only
htmand normalizedhtumatch requestathmatches access token hash (for access-token protected routes)- Nonce required and one-time consumed
jtireplay detection in shared DB cache
If nonce is missing/invalid, server responds with:
401WWW-Authenticate: DPoP error="use_dpop_nonce"DPoP-Nonce: <nonce>
POST /api/auth/mfa/enroll/start- Server returns:
otp_auth_uriqr_data_url- masked
manual_key expires_at
POST /api/auth/mfa/enroll/verifywith first TOTP code
POST /api/auth/loginmay returnmfa_required+challenge_idPOST /api/auth/mfa/completevalidates code and issues auth cookies/tokens
Security details:
- MFA secrets encrypted at rest (AES-256-GCM via
PSS_MASTER_KEY) - Enrollment and challenge TTL enforced
- Challenge one-time use (
consumed_at) - Last used timestep tracked to reduce code replay
users.rolereferencesroles.role_key- Protected routes use:
authenticateAccessTokenrequirePermission("...")
- Permissions are enforced server-side on every protected route
Current permission examples:
audit:readsettings:read,settings:writeusers:managesessions:revokeprofile:read,profile:writereports:readtools:use
Redis-backed per-route rate limiters are applied to sensitive auth endpoints:
- Login by IP and account key
- Refresh by IP
- MFA completion and verification by IP
Headers exposed:
X-RateLimit-LimitX-RateLimit-RemainingRetry-After(on 429)
If Redis is unavailable, middleware fails open and logs an error.
Template logs key auth and admin actions into audit_logs.
Captured fields include:
- Actor identity (
user_id,username,role) - Action and resource
- HTTP method/path
- Status code
- Source IP and user-agent
- JSON metadata
- Timestamp
Examples:
- Login success/failure
- Refresh success/failure/reuse detection
- Logout
- Force logout operations
- All services expose
/api/health core-apialso exposes/api/components(DB check + service placeholders)
Use these for:
- Container health checks
- Smoke tests after deployment
- Admin status dashboards
POST /api/auth/loginPOST /api/auth/refreshPOST /api/auth/logoutPOST /api/auth/users/:userId/force-logoutPOST /api/auth/mfa/completePOST /api/auth/mfa/enroll/startPOST /api/auth/mfa/enroll/verify
GET /api/profile/mePATCH /api/profile/me
GET /api/audit/logsGET /api/settings/securityPATCH /api/settings/security(scaffold, returns not implemented)
GET /api/tools/pingGET /api/reports/summaryPOST /api/reports/export
GET /api/components
client/src/lib/dpop.ts provides:
- DPoP keypair generation/export
- Payload helper
withDpopJwk(...) DpopClient.fetch(...)that:- Adds DPoP proof header
- Adds
Authorization: DPoP ...when access token is provided - Automatically attaches
X-CSRF-Tokenfor non-safe methods - Sends credentials by default (
include) - Retries once on nonce challenge
docker compose builddocker compose up -ddocker compose logs -f edge auth-service core-api tools-service reports-service postgres redisdocker compose downdocker compose down -v- Create
server/src/<service>.tsentrypoint - Add service route module(s)
- Register service in
docker-compose.yml - Add nginx upstream + route mapping in
deploy/nginx.conf - Add health checks and audit hooks
- Update this README endpoint inventory
- Define zod input schema
- Add
authenticateAccessTokenmiddleware - Add
requirePermission("...") - Implement DB queries with parameterized SQL only
- Add audit event write for mutating operations
- Update role seeds if new permission is introduced
- Add permission string to
rolesseed inserver/src/db.ts - Add enforcement middleware on route(s)
- Update docs + tests
- Append idempotent SQL statement(s) in migration list
- Prefer additive changes (
ADD COLUMN IF NOT EXISTS, new table/index) - Backfill in deterministic steps
- Do not rewrite existing migrations in ways that break existing DB states
Before production rollout:
- Replace all default secrets (
JWT_SECRET,PSS_MASTER_KEY, DB password) - Use trusted CA certificates (not self-signed)
- Run with
NODE_ENV=production - Disable dev seed account path
- Set strong Redis password and isolated network policy
- Consider mandatory DPoP for all human sessions
- Add automated backup/restore for PostgreSQL
- Add centralized logging/metrics/tracing
- Add SAST/DAST and dependency scanning in CI
- Add integration tests for auth/refresh/DPoP/MFA critical paths
Symptom:
TS7016: Could not find a declaration file for module 'pg'
Fix:
- Ensure
@types/pgexists inserver/devDependenciesand run install/build again.
Symptom:
- Duplicate key for object name such as
user_preferences
Likely causes:
- Race conditions from concurrent migration runs
- In-flight/partial object creation during repeated starts
Current mitigation in template:
- Advisory lock around migration execution
If issue persists:
- Ensure only one migrator process starts first
- Recreate DB volume in non-production if state is corrupted
- Inspect catalog/object names before retrying migration
- Client must replay request with returned
DPoP-Nonceheader value DpopClient.fetchalready handles one automatic retry
- Ensure
csrf_tokencookie exists - Send same value in
X-CSRF-Token - Ensure request includes credentials/cookies
- Observe
Retry-Afterand back off - Tune limiter windows/thresholds if needed
This template is a secure scaffold, not a full product. You still need to implement:
- Actual domain models and business logic
- Reporting storage/queue/file lifecycle
- Settings mutation persistence logic
- Full integration and end-to-end test suites
- CI/CD, secret management, and production observability setup
- Add automated integration tests for login, refresh, DPoP, MFA, and RBAC.
- Decide whether to mandate DPoP for all interactive users.
- Add a domain module (for example employee records + approval flows) following the RBAC/audit patterns in this template.