Skip to content

Security

This document covers security practices and configuration for the Coaching App platform.

Table of Contents


Credentials and Secrets

Production Secret Rotation Checklist

Before every production deployment, verify:

  • BETTER_AUTH_SECRET is random (32+ characters) — rotate if compromised
  • DATABASE_URL uses a dedicated app user (not postgres superuser)
  • MinIO credentials are changed from defaults
  • SMTP credentials are production credentials, not dev placeholders
  • All .env files 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: .env files (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 expiresIn in backend/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-min setInterval on web; AppState + 30-min interval on native
  • All /api/* routes require a valid bearer token (enforced by requireAuth middleware)

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
  },
});

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: rejectUnauthorized is true in production to prevent MITM interception of password-reset links and invite emails. Set to false only 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 a PlatformAdmin or an OrgAdmin/Manager of the target organization may update the logo. Passing a foreign organizationId is rejected with 403.
  • Mentorship attachments: callers must be a direct participant of the mentorship or an OrgAdmin/Manager of 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

MINIO_ROOT_USER=<random-string>
MINIO_ROOT_PASSWORD=<random-32-char-string>

Enable SSE (server-side encryption) in production:

mc encrypt set sse-s3 myminio/coaching-app

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

  1. Immediately rotate BETTER_AUTH_SECRET — this invalidates all active sessions
  2. Rotate database password and update DATABASE_URL
  3. Rotate MinIO credentials
  4. Review logs for unauthorized access patterns
  5. Notify affected users

Security Checklist

Development

  • .env files are in .gitignore
  • .env.staging and other environment-specific files are in .gitignore
  • No hardcoded credentials in source code
  • Zod validation on all API inputs
  • requireAuth middleware 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 --prod returns zero critical or high severity results
  • All new file endpoints sanitize the Content-Disposition filename
  • Non-image/video files are served with Content-Disposition: attachment

Deployment

  • NODE_ENV=production set
  • BETTER_AUTH_SECRET is random and 32+ characters
  • CLIENT_API_KEY is random and 32+ characters
  • CORS_ORIGIN is 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=true or port 587 with valid cert)
  • Better-auth rate limiting enabled (rateLimit.enabled: true in auth.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 /:id and DELETE /:id include 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.4setCookie() 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