nextjsboilerplate Docs
Guides

Compliance moat

Audit chain (WORM), RGPD export & deletion, idempotent webhooks, CSP. The compliance primitives that turn a SaaS demo into a SaaS your CFO will sign off on — with code examples.

Compliance moat

The marketing site at /security lists the compliance primitives. This page shows the code. Every claim below maps to a file path you can grep, a migration you can roll back, and a server action you can call.

1. Tamper-evident audit chain (WORM)

A SHA-256-linked, append-only hash chain in audit_chain (migration 0016-audit-chain), strictly separate from the operational audit_log table.

When to use which

Event classTableWhy
Operational ("user signed in", "preferences updated")audit_logHigh volume, no compliance review, no chain integrity required.
Compliance-grade ("subscription upgraded", "deletion scheduled")audit_chainSOC 2 / HIPAA / SEC reviewer expects an immutable, hash-linked trail.

When in doubt, emit to both.

Append a row

import { AuditChain } from '@/libs/AuditChain';

try {
  await AuditChain.append({
    orgId: 'org_…',
    userId: 'usr_…',         // optional — null for system events
    kind: 'subscription.upgraded',
    payload: { tier: 'pro', priceId: 'price_…' },
    worm: true,              // legally retention-bound → DB-level no-delete
  });
}
catch (err) {
  logger.warn(`audit-chain append failed: ${err}`);
}

The append is wrapped in try / catch because the chain is best-effort — it must never block the parent business flow. The CI verifier (npx tsx src/scripts/audit-verify.ts --org <slug>) catches any tampering that silently bypasses both the trigger fallback and the RLS layer.

Verify a chain end-to-end

const verdict = await AuditChain.verify('org_…');
// → { valid: true, totalRows: 42 }
// or { valid: false, brokenAtId: 17, totalRows: 42 }

CLI form, suitable for a daily cron in any environment that handles compliance data :

npx tsx src/scripts/audit-verify.ts --org acme-inc
# PASS — 1 247 rows verified for org acme-inc

npx tsx src/scripts/audit-verify.ts --org-id org_01HXYZ
# FAIL — chain broken at id 178

Exit codes : 0 PASS, 1 FAIL, 2 org not found.

Tamper-evidence enforcement (4 independent safeguards)

Migration 0016-audit-chain ships :

  1. ALTER TABLE audit_chain ENABLE ROW LEVEL SECURITY plus FOR UPDATE, FOR DELETE policies that always evaluate false.
  2. A BEFORE UPDATE trigger that RAISE EXCEPTIONs on every UPDATE.
  3. A BEFORE DELETE trigger that RAISE EXCEPTIONs on every DELETE.
  4. The unique (org_id, id) index — concurrent appenders serialise via the for('update') lock on the previous row, so two writers can never share the same prev_hash.

If a future PGlite build rejects RLS policy syntax, the trigger fallback is the load-bearing safeguard.

WORM (write-once-read-many)

Set worm: true on append for events that are legally or contractually retention-bound : RGPD data export, account deletion confirmation, Stripe payment lifecycle (PSP-mandated retention), KYC checkpoints. The audit_chain.worm column flips a Postgres trigger that refuses DELETE on the row at the database level — even a superuser would have to disable the trigger first, which is itself an audit event you'd have to defend.

Promote at emission time. Promoting after the fact would defeat the tamper-evidence guarantee and is intentionally not supported.

Currently emitting

  • src/app/api/stripe/webhook/route.tssubscription.upgraded, subscription.downgraded, subscription.canceled, invoice.paid, invoice.failed.
  • src/actions/invites.tsinvite.sent, invite.revoked.
  • src/actions/account.tsaccount.deletion_scheduled, account.deletion_cancelled, data_export.requested.
  • src/actions/suppression.tssuppression.created, suppression.revoked.

2. RGPD data export

"Download my data" lives at /dashboard/settings?tab=security.

Trigger

// src/actions/account.ts
export async function requestDataExport() {
  const exportId = nanoid();

  await db.insert(schema.dataExports).values({
    id: exportId,
    userId: user.id,
    status: 'pending',
  });

  await getJobsAdapter().enqueue('account/export.requested', {
    userId: user.id,
    exportId,
  });

  await AuditChain.append({
    orgId: org.id,
    userId: user.id,
    kind: 'data_export.requested',
    payload: { exportId },
    worm: true,
  });
}

Job

src/jobs/account.ts:exportUserData collects every user-scoped row (profile, memberships, audit_log, credits_ledger, payments, subscriptions, preferences), gzips a JSON blob, uploads it to exports/{userId}/{exportId}.json.gz via Storage.put(), marks the row ready (24h TTL), and emails a signed download link via Email.sendDataExportReady().

Rollback

Drop migration 0014-rgpd-and-deletion, remove src/jobs/account.ts, src/actions/account.ts, the two email templates, and the data_exports table. Existing users keep working.

3. 30-day account deletion

// src/actions/account.ts
export async function requestAccountDeletion(typedEmail: string) {
  if (typedEmail !== user.email) {
    return { ok: false, error: 'email_mismatch' };
  }

  const token = crypto.randomBytes(24).toString('base64url');
  const scheduled = new Date(Date.now() + 30 * 24 * 60 * 60 * 1000);

  await db.update(schema.users).set({
    scheduledDeletionAt: scheduled,
    deletionToken: token,
  }).where(eq(schema.users.id, user.id));

  await Email.sendAccountDeletionScheduled(user.email, cancelUrl(token), locale);

  await AuditChain.append({
    orgId: org.id,
    userId: user.id,
    kind: 'account.deletion_scheduled',
    payload: { scheduledAt: scheduled.toISOString() },
    worm: true,
  });
}

The user can sign in any time during the 30 days and click the cancel link /cancel-deletion/{token} to revert. After the deadline, the daily cron process-scheduled-deletions (registered in src/jobs/registry.ts, 0 3 * * * UTC) hard-deletes every user past the deadline. FK onDelete: cascade covers memberships, sessions, ledger, payments, subscriptions, data exports.

4. Idempotent webhooks

Every API route or webhook that mutates state can opt into the standard Idempotency-Key contract. The library lives at src/libs/Idempotency.ts and the backing table comes from migration 0019-idempotency.

Contract

  • No Idempotency-Key header → handler runs unchanged.
  • Same key + matching request_hash → cached response replayed (Idempotency-Replayed: true).
  • Same key + different request_hash409 Conflict with body { "error": "idempotency_key_request_mismatch" }.
  • New key → handler runs, response captured + persisted with a 24h TTL.

request_hash is sha256(method | path | canonicalBody). JSON bodies are key-sorted before hashing so semantically equal payloads share the same hash. Only 2xx and 4xx responses are cached — 5xx replays would mask real outages.

Wire a handler

import { Idempotency } from '@/libs/Idempotency';

export async function POST(request: Request) {
  return Idempotency.handle(request, async () => {
    // your handler — returns NextResponse / Response
    return NextResponse.json({ ok: true });
  });
}

Currently wired

  • GET /api/v1/me — universal opt-in for any caller passing the header.
  • POST /api/stripe/webhook — keyed by Stripe-Event-Id so retried events replay instead of re-firing audit-chain rows + notifications.

Cleanup

The daily cron idempotency-cleanup (30 4 * * * UTC) deletes rows whose expires_at is in the past. The job is itself idempotent and safe to run repeatedly.

5. Content Security Policy + security.txt

Production hardening defaults shipped in next.config.ts and src/app/.well-known/security.txt/route.ts.

Headers applied on every route in production

// next.config.ts (excerpt)
const cspDirectives = [
  "default-src 'self'",
  "script-src 'self' 'unsafe-inline' https://*.posthog.com",
  "style-src 'self' 'unsafe-inline' https://fonts.googleapis.com",
  "connect-src 'self' https://*.sentry.io https://*.posthog.com https://api.stripe.com",
  "frame-src 'self' https://js.stripe.com https://hooks.stripe.com",
  "frame-ancestors 'none'",
  "object-src 'none'",
  "base-uri 'self'",
  "form-action 'self'",
  'upgrade-insecure-requests',
].join('; ');

export const securityHeaders = [
  { key: 'Content-Security-Policy', value: cspDirectives },
  { key: 'Strict-Transport-Security', value: 'max-age=63072000; includeSubDomains; preload' },
  { key: 'X-Content-Type-Options', value: 'nosniff' },
  { key: 'X-Frame-Options', value: 'DENY' },
  { key: 'Referrer-Policy', value: 'strict-origin-when-cross-origin' },
  { key: 'Permissions-Policy', value: 'camera=(), microphone=(), geolocation=(), payment=(self), interest-cohort=()' },
  { key: 'X-DNS-Prefetch-Control', value: 'on' },
];

CSP is skipped in development so HMR + the Next.js dev overlay keep working. Forks adding a new third-party host (chat widget, analytics CDN) edit cspDirectives'unsafe-eval' is permanently banned.

security.txt

GET /.well-known/security.txt returns an RFC 9116 payload assembled from env vars :

Contact: mailto:security@yourdomain.example
Expires: 2027-04-30T00:00:00.000Z
Preferred-Languages: en, fr
Canonical: https://yourdomain.example/.well-known/security.txt
Policy: https://yourdomain.example/security-policy

Expires is computed at request time as now() + 12 months, so the file never goes stale. Configure via :

SECURITY_CONTACT_EMAIL=security@yourdomain.example
SECURITY_POLICY_URL=https://yourdomain.example/security

6. Suppression — reversible "noise reduction"

Use the reusable primitives, never inline a bespoke flow :

  • <SuppressFindingButton> — row-action button (entity-kind agnostic).
  • <SuppressionReasonDialog> — pre-built reason picker (5 codes + note
    • optional expiry).
  • <SuppressedRowBadge> — popover showing actor / reason / when.
  • Suppression.activeSuppressionsFor(...) — single-batch lookup for table renderers (no N+1).

Owner / admin gated server-side. Revocation emits its own audit-chain event — no record is ever destroyed. There is no delete server action and no UI affordance, on purpose.

7. Evidence completeness

Generic red / amber / green bars for any audit / compliance entity. Mounted on /dashboard/activity/[id] as the working example. Three sample checklists ship in devSeed.ts : chargeback (8 items), leakage (6 items), compliance (10 items).

import { EvidenceCompletenessBar } from '@/components/dashboard/EvidenceCompletenessBar';

<EvidenceCompletenessBar
  entityKind="audit_log"
  entityId={row.id}
  checklistKind="audit"
  canEdit={role === 'owner' || role === 'admin'}
/>

The score is 0..100, mapped to red < 50, amber 50-79, green >= 80 by the pure helper Evidence.scoreBand(score). Items + records are read in a single batch — no N+1.

What's next

  • Email layer — the per-category preferences and HMAC unsubscribe links that keep CASL / CAN-SPAM regulators happy.
  • Customizing the boilerplate — extend or replace any of the primitives above without breaking the rest.

On this page