Security¶
This document covers security practices and configuration for the Coaching App platform.
Table of Contents¶
- Credentials and Secrets
- Authentication and Sessions
- Role-Based Access Control
- Database Security
- Network Security
- HTTP Security Headers
- Input Validation and Injection Prevention
- File Storage Security
- Data Encryption
- Email Security
- Monitoring and Incident Response
- Security Checklist
- Security Hardening History
Credentials and Secrets¶
Production Secret Rotation Checklist¶
Before every production deployment, verify:
-
BETTER_AUTH_SECRETis random (32+ characters) — rotate if compromised -
DATABASE_URLuses a dedicated app user (not postgres superuser) - MinIO credentials are changed from defaults
- SMTP credentials are production credentials, not dev placeholders
- All
.envfiles are excluded from git (check.gitignore)
Generating Secrets¶
# Generate a secure random secret
openssl rand -base64 32
# Or use Node.js
node -e "console.log(require('crypto').randomBytes(32).toString('hex'))"
Secret Storage¶
- Development:
.envfiles (never committed) - Production: Docker secrets, environment variables injected by the host, or a secrets manager (Vault, AWS SSM)
# docker-compose.prod.yml — use Docker secrets
secrets:
better_auth_secret:
external: true
db_password:
external: true
services:
backend:
secrets:
- better_auth_secret
- db_password
environment:
BETTER_AUTH_SECRET_FILE: /run/secrets/better_auth_secret
Authentication and Sessions¶
Authentication is handled by Better-auth. Sessions are stored server-side in the session table.
Session Security¶
- Sessions expire after 7 days of inactivity (configured via
expiresIninbackend/src/lib/auth.ts) - Session tokens are opaque strings sent as
Authorization: Bearer <token>— never exposed as cookies (React Native compatibility) - Server-side session sliding fires once per hour of activity (
updateAge: 60 * 60) - Frontend keep-alive: tab
visibilitychange+ 30-minsetIntervalon web;AppState+ 30-min interval on native - All
/api/*routes require a valid bearer token (enforced byrequireAuthmiddleware)
Rate Limiting¶
Better-auth's built-in rate limiter is enabled on all auth endpoints (sign-in, sign-up, forgot-password, reset-password). The default configuration allows 10 requests per IP per 60-second window:
rateLimit: {
enabled: true,
window: 60, // seconds
max: 10, // requests per window
storage: 'memory',
},
This prevents brute-force credential attacks and token enumeration. For high-traffic deployments, switch storage to 'redis' and pass the Redis client to persist counters across multiple server instances.
Password Policy¶
Better-auth enforces password requirements. Minimum recommended settings in backend/src/lib/auth.ts:
export const auth = betterAuth({
emailAndPassword: {
enabled: true,
minPasswordLength: 8,
// Enable email verification for production
requireEmailVerification: process.env.NODE_ENV === 'production',
},
session: {
expiresIn: 60 * 60 * 24 * 7, // 7 days
updateAge: 60 * 60, // Slide server-side once per hour of activity
},
});
HTTPS / Cookie Security¶
In production all traffic must go through Cloudflare (HTTPS). The backend must set:
BETTER_AUTH_URL=https://api.yourdomain.com
NODE_ENV=production # Enables secure cookie flags automatically
When NODE_ENV=production, Better-auth sets Secure, HttpOnly, and SameSite=Lax on session cookies.
Role-Based Access Control¶
See Permissions and Roles for the full role matrix.
Principle of Least Privilege¶
- Each role has access only to the data it needs.
- Middleware enforces role checks before hitting business logic:
// Example: Only platform_admin can list all organizations
app.get('/api/organizations', requireAuth, requireRole('platform_admin'), listOrganizationsHandler);
- Organization-scoped routes verify the requesting user belongs to the target organization before returning data.
Database Security¶
Dedicated Database User¶
Never connect to the database as the postgres superuser in production.
-- Create a least-privilege user for the app
CREATE USER coaching_app WITH PASSWORD 'strong-random-password';
GRANT CONNECT ON DATABASE coaching_app TO coaching_app;
GRANT SELECT, INSERT, UPDATE, DELETE ON ALL TABLES IN SCHEMA public TO coaching_app;
GRANT USAGE, SELECT ON ALL SEQUENCES IN SCHEMA public TO coaching_app;
-- Prevent the app user from creating tables (Drizzle migrations run separately)
REVOKE CREATE ON SCHEMA public FROM coaching_app;
SQL Injection Prevention¶
Drizzle ORM uses parameterized queries exclusively — never build raw SQL from user input. When filtering by a list of IDs, use Drizzle's inArray() helper instead of sql.raw():
// ✅ Safe: parameterized via Drizzle
const user = await db.select().from(users).where(eq(users.email, userInput));
// ✅ Safe: inArray generates a parameterized ANY($1) query
const rows = await db.select().from(users).where(inArray(users.id, userIds));
// ❌ Never do this — sql.raw() bypasses parameterization
await db.execute(sql.raw(`SELECT * FROM users WHERE email = '${userInput}'`));
await db
.select()
.from(users)
.where(sql`${users.id} = ANY(${sql.raw(`ARRAY[${userIds.map((id) => `'${id}'`).join(',')}]`)})`);
Backups¶
# Create encrypted backup
pg_dump -U coaching_app coaching_app | gpg --encrypt --recipient admin@example.com > backup.sql.gpg
# Restore
gpg --decrypt backup.sql.gpg | psql -U coaching_app coaching_app
Network Security¶
Cloudflare Zero Trust¶
All production traffic is proxied through Cloudflare, which provides:
| Concern | Cloudflare Feature |
|---|---|
| Rate limiting | WAF Rate Limiting Rules |
| DDoS protection | Auto-enabled on all plans |
| Bot management | Challenge pages + JS validation |
| App authentication | Service tokens per client (web vs mobile) |
See Cloudflare Zero Trust Setup for full configuration.
Docker Network Isolation¶
Services communicate over an internal Docker network — nothing is exposed to the host except the ports explicitly mapped:
# Only backend and mailpit ports are publicly accessible in dev
services:
backend:
ports:
- '3001:3001'
postgres:
# No host port in production — only accessible within Docker network
redis:
# No host port in production
HTTP Security Headers¶
The backend applies hono/secure-headers middleware globally. This sets:
| Header | Value |
|---|---|
X-Content-Type-Options |
nosniff |
X-Frame-Options |
DENY |
X-XSS-Protection |
1; mode=block |
Referrer-Policy |
no-referrer |
Strict-Transport-Security |
max-age=15552000; includeSubDomains |
// backend/src/app.ts
import { secureHeaders } from 'hono/secure-headers';
app.use('*', secureHeaders());
Email Security¶
- SMTP TLS:
rejectUnauthorizedistruein production to prevent MITM interception of password-reset links and invite emails. Set tofalseonly in non-production environments where self-signed certs are used. - Template injection: All user-supplied values interpolated into HTML email templates are HTML-escaped before substitution, preventing email layout manipulation or phishing via crafted profile fields.
Input Validation and Injection Prevention¶
All incoming request bodies must be validated with Zod before processing:
import { z } from 'zod';
const createOrgSchema = z.object({
name: z.string().min(1).max(100),
slug: z
.string()
.regex(/^[a-z0-9-]+$/)
.max(50),
});
app.post('/api/organizations', requireAuth, async (c) => {
const body = createOrgSchema.safeParse(await c.req.json());
if (!body.success) return c.json({ error: body.error.format() }, 400);
// ...
});
File Storage Security¶
Files are stored in MinIO (S3-compatible). In production, expose MinIO via pre-signed URLs only — never make buckets public.
// Generate a pre-signed URL (expires in 1 hour)
const url = await minioClient.presignedGetObject('coaching-app', objectKey, 60 * 60);
File Upload ACL¶
Every file upload endpoint enforces authorization before modifying org-owned data:
- Avatar uploads (
target=avatar): only update the uploading user's own profile. - Org logo uploads (
target=orgLogo): only aPlatformAdminor anOrgAdmin/Managerof the target organization may update the logo. Passing a foreignorganizationIdis rejected with403. - Mentorship attachments: callers must be a direct participant of the mentorship or an
OrgAdmin/Managerof the mentorship's organization.
The legacy GET /api/files/:key endpoint (which returned a signed URL for any storage key without ACL checks) has been removed. Use GET /api/files/:fileId which enforces org-scoped access checks.
Stored XSS Prevention¶
The GET /api/files/:fileId/stream endpoint sets Content-Disposition: attachment for all non-image, non-video MIME types. This prevents browsers from rendering uploaded HTML, JavaScript, or SVG files inline, which would be a stored XSS vector.
Images and videos that pass MIME-type validation are served with Content-Disposition: inline.
Content-Disposition Header Injection Prevention¶
Filenames in Content-Disposition headers are sanitized before use: characters that could be used for HTTP header injection (\r, \n, ", ;, \, /) are replaced with underscores, and the result is limited to printable ASCII and capped at 200 characters.
Storage Key Path Validation¶
The POST /api/files/register endpoint only accepts storage keys that match the server-generated format (<prefix>/<uuid>.<ext>). This prevents a client from registering a storage key they did not obtain via POST /api/files/presign (e.g. injecting keys that point to other users' files or to paths outside the upload prefix).
Upload Prefix Allowlist¶
The prefix field accepted by POST /api/files/presign is validated against a pattern that permits only alphanumeric characters, hyphens, underscores, and forward slashes, with a 64-character maximum. Prefixes starting with / or containing .. segments are rejected.
MinIO credentials¶
Enable SSE (server-side encryption) in production:
Data Encryption¶
In Transit¶
- All production traffic encrypted via Cloudflare TLS (TLS 1.2+ enforced)
- Internal Docker service communication uses private networks (no TLS needed within the stack)
At Rest¶
- PostgreSQL data volume should be on encrypted storage (cloud provider disk encryption)
- MinIO supports SSE-S3 (enable in production)
Sensitive Fields¶
Never store plaintext passwords, tokens, or PII without need. Better-auth hashes passwords using Argon2 by default.
Monitoring and Incident Response¶
Health Checks¶
# Backend API health
curl http://localhost:3001/api/health
# Postgres connectivity
docker compose exec postgres pg_isready -U coaching_app
Vulnerability Scanning¶
# Scan Docker images
docker scout cves ghcr.io/your-org/coaching-app/backend:latest
# Or with Trivy
trivy image ghcr.io/your-org/coaching-app/backend:latest
Run pnpm audit --prod regularly to check for vulnerable npm packages in production dependencies. The repository uses pnpm.overrides in the root package.json to pin vulnerable transitive dependencies to their patched versions. Always update these overrides when new advisories are published.
# Check only production dependencies (excludes dev tools)
pnpm audit --prod
# Fix direct dependency vulnerabilities
pnpm update --latest --filter @coaching-app/backend
# Update overrides in package.json when new advisories are released
# (pnpm.overrides section)
Suspected Breach Response¶
- Immediately rotate
BETTER_AUTH_SECRET— this invalidates all active sessions - Rotate database password and update
DATABASE_URL - Rotate MinIO credentials
- Review logs for unauthorized access patterns
- Notify affected users
Security Checklist¶
Development¶
-
.envfiles are in.gitignore -
.env.stagingand other environment-specific files are in.gitignore - No hardcoded credentials in source code
- Zod validation on all API inputs
-
requireAuthmiddleware on all protected routes - All file upload endpoints verify caller's org membership before updating org resources
- Parameterized queries used throughout (no
sql.raw()with user-controlled data) -
pnpm audit --prodreturns zero critical or high severity results - All new file endpoints sanitize the
Content-Dispositionfilename - Non-image/video files are served with
Content-Disposition: attachment
Deployment¶
-
NODE_ENV=productionset -
BETTER_AUTH_SECRETis random and 32+ characters -
CLIENT_API_KEYis random and 32+ characters -
CORS_ORIGINis set to the exact frontend origin(s) — server refuses to start without it in production - Database uses a least-privilege user
- All secrets injected via environment (not baked into image)
- Cloudflare proxy enabled for the backend domain
- SMTP uses a verified TLS connection (
SMTP_SECURE=trueor port 587 with valid cert) - Better-auth rate limiting enabled (
rateLimit.enabled: trueinauth.ts)
Access Control (run before every PR touching users/orgs)¶
- Routes guarded by
requireRole(...)with the minimum required set of roles - Org-scoped DB queries include
isNull(user.globalRole)to exclude platform admins -
GET /:idandDELETE /:idinclude ceiling checks — lower-privilege callers cannot act on higher-privilege users - File upload endpoints verify org membership before modifying org-owned data
- Session/agora-token endpoints verify org scope for OrgAdmin/Manager callers
Resources¶
Security Hardening History¶
A log of significant security improvements made to the codebase.
2026-04 — Comprehensive Security Audit¶
| Category | Issue | Fix |
|---|---|---|
| Critical CVE | fast-xml-parser <5.3.5 — entity encoding bypass via DOCTYPE regex injection (via @aws-sdk) |
Upgraded @aws-sdk/client-s3 and @aws-sdk/s3-request-presigner to latest; added pnpm override fast-xml-parser >=5.5.6 |
| High CVE | nodemailer <=7.0.10 — DoS via recursive addressparser |
Upgraded nodemailer from ^6.10.0 to ^8.0.6 |
| Low CVE | nodemailer <8.0.4 — SMTP command injection via envelope.size |
Included in the nodemailer ^8.0.6 upgrade |
| High CVE | hono <4.12.4 — setCookie() header injection; serveStatic path bypass; SSE CRLF injection |
Upgraded hono from ^4.7.4 to ^4.12.15 |
| High CVE | @hono/node-server <1.19.10 — authorization bypass for static paths |
Upgraded to ^2.0.0 |
| High CVE | drizzle-orm <0.45.2 — SQL injection via improperly escaped SQL identifiers |
Upgraded drizzle-orm from ^0.40.0 to ^0.45.2 |
| High CVE | kysely <=0.28.13 — MySQL SQL injection via sql.lit() (transitive via better-auth) |
Added pnpm override kysely >=0.28.14 |
| High CVE | tar <7.5.10 — path traversal (transitive via drizzle-orm/sqlite3) |
Added pnpm override tar >=7.5.10 |
| High CVE | picomatch <4.0.4 — ReDoS via extglob quantifiers (transitive) |
Added pnpm override picomatch >=4.0.4 |
| Stored XSS | Files with text/* MIME type served with Content-Disposition: inline, allowing HTML/JS rendering |
Changed to Content-Disposition: attachment for all non-image/non-video MIME types |
| Header injection | Unsanitized filenames used in Content-Disposition header |
Added sanitizeFilename() helper that strips \r, \n, ", ;, \, / and non-ASCII chars |
| Path injection | prefix parameter in presign endpoint accepted arbitrary strings |
Added regex validation: only [a-zA-Z0-9/_-] allowed, max 64 chars |
| Storage key injection | /api/files/register accepted any storageKey value, letting clients claim unowned files |
Added format validation: key must match <prefix>/<uuid>.<ext> pattern |
| Brute force | No rate limiting on auth endpoints (sign-in, forgot-password) | Enabled better-auth built-in rateLimit (10 req/60s/IP) |
| Env inconsistency | Root .env.example used SMTP_PASSWORD but code reads SMTP_PASS |
Fixed .env.example to use SMTP_PASS; added missing CLIENT_API_KEY and FRONTEND_URL entries |
| Major update | better-auth ^1.2.7 |
Upgraded to ^1.6.9 for bundled security patches |