Security and privacy
Claude skill atlas-security · for Backend, Frontend, Release
Security and GDPR rules for Atlas - sign-in (getUser, invitations, 2FA), permissions, Server Actions as public endpoints, HTML sanitizing, safe URLs and redirects, the embed allowlist, CSP and headers, Turnstile, rate limits, secrets, GitHub Actions hardening, dependencies, uploads, logging, and personal data (minimisation, retention, export and deletion, cookie-less analytics, newsletter consent). Use when touching auth, roles, forms, HTML, URLs, embeds, headers, env vars, workflows, uploads or any personal data.
Atlas is a public repo with an invite-only editorial team and self-registered readers.
Every request is untrusted until the database says otherwise. Defence in depth: the app
checks for the user's sake, RLS decides for real (atlas-architecture, rule 1).
Before you start
- DB-side rules (RLS, grants, definer functions, migrations):
atlas-backend. - Sensitive paths have a code owner (
.github/CODEOWNERS):supabase/,src/proxy.ts,src/lib/security/,src/lib/supabase/,.github/. A PR touching them fills in the Security impact section of the PR template. - The repo is public. State rules, never weaknesses. Findings and their status live only in the
gitignored
docs/; refer to them by ID. Report channel:SECURITY.md,public/.well-known/security.txt. - Detail, secret inventory and the key-leak runbook: reference.
Authentication
- On the server, identity is always
supabase.auth.getUser()(signedIn()inlib/actions.ts,currentUser()inlib/supabase/server.ts). Never trustgetSession()alone. - Sign-in: Google OAuth (
features/auth/oauth-actions.ts) and an emailed code (features/auth/email-actions.ts). No passwords. The email code is 8 digits and expires after 3600 s (supabase/config.toml); this is an owner decision, keep it. - Every
next/ return URL goes throughsafeRedirect(value, fallback)(lib/security/redirect.ts); sign-in ends infinishSignIn/signInDestination. - Readers self-register as
readerwith no admin access. The team joins only by invitation:invitations+claim_invitation(), which accepts only a verified email equal to the invitation. The link carries no secret. - 2FA is required for the
adminandpermission-adminroles only (security_settings.require_2fa_roles;has_permthen demandsaal2). The admin layout showsMfaGate(features/auth/mfa.ts). Do not extend it to other roles without the owner. - Authorize from DB tables (
my_permissions(),has_perm()), never fromuser_metadata. - Session cookies:
SESSION_COOKIE_OPTIONS(lib/supabase/config.ts): httpOnly, SameSite=Lax, Secure on Vercel. The proxy refreshes the session only onSESSION_PATHS.
Permissions
- Model: role × section × action (
v/c/e/d). Sections:src/config/sections.ts. App helpers:getAccess(),can(),isStaff(),sectionAccess()(features/auth/access.ts). - A new section = DB
CHECK+my_permissions()+config/sections.ts+features/roles/labels.ts+ DB tests (atlas-backend). - Admin pages:
const access = await sectionAccess("x"); if (!access) return <NoAccess />;. No access is a 200 page, neverforbidden()(atlas-architecture). - Anyone invited into the team may edit content. Do not add editorial approval gates.
Server Actions and route handlers are public endpoints
- Anyone can POST to any action, whatever page it sits on. Each action authenticates
(
formAction/idAction/signedIn()) and is authorized by RLS through the session client;affected()turns a refused write into a message. A page or layout check protects nothing. - Validate every argument with Zod, including hidden fields, bound ids and
params. - Return minimal data. Never close over secrets in an inline
"use server"function. - The service client runs only after the action proved permission with an RLS-checked write, or in a deliberately public endpoint with rate limit and captcha.
route.tshandlers: GET never changes state. A state-changing POST rejects a foreignOrigin(seeapp/auth/signout/route.ts).src/proxy.tsdoes optimistic checks only (redirect to/login, 401 JSON for/api/admin). It is never the only gate.
Untrusted HTML and URLs
- Rich text passes one allowlist:
sanitizeRichHtml(lib/security/sanitize.ts) at save (inside the Zod schema) and again at render via<SafeHtml>(components/atlas/SafeHtml.tsx). Map popups usesanitizePopupHtml. JSON-LD goes through<JsonLd>(jsonLdHtmlescaping). dangerouslySetInnerHTMLexists only inSafeHtml,JsonLdandPrePaintScript(ESLint). Do not widen the allowlist without a test insanitize.test.ts.- Any URL from data into
href,srcorstylegoes throughsafeUrl()/cssBackgroundImage()(lib/security/urls.ts). Forms validate withhttpsUrl/optionalHttpsUrl(lib/validation/common.ts); the DB adds^https://checks. - Photos load only from trusted origins (
isTrustedPhoto,PHOTO_ORIGINSinlib/images.ts, whichnext.config.tsuses). - Server-side fetches of an editor-typed URL follow
lib/previews/preview-image.ts: https only, public IPs only, pinned address, at most 3 redirects, 5 s, 512 kB. Never fetch a user URL ad hoc.
Embeds, CSP and headers
- Embeds: one allowlist in
lib/embeds.ts(Datawrapper, Flourish, Our World in Data, World Bank) drives Zod, the DBCHECKand CSPframe-src. Only the canonical URL is stored, never pasted HTML.components/visuals/EmbedFrameis the only iframe (sandboxed, lazy, height messages only from the provider origin). A new provider =lib/embeds.ts+ a migration for theCHECK+csp.test.ts. - CSP is built in
lib/security/csp.tsand set bysrc/proxy.ts. There are two policies (ADR-025):- Session pages (
NONCE_PATHS: admin, /auth, login, account, invite, checkout) get a fresh nonce per request:'nonce-...' 'strict-dynamic'plus the hash ofPrePaintScript. Every page underNONCE_PATHSmust render dynamically, or its scripts will not run. - Static and ISR public pages share one policy with
'unsafe-inline', because a nonce would make every page dynamic. XSS there is prevented by sanitizing and the ESLint ban, andscript-src-attr 'none'blocks inline event handlers. - Never add
'unsafe-eval'outside development. Never add a public page toNONCE_PATHS. - A new third-party origin = add it to the right directive in
csp.ts+ a case incsp.test.ts. No wildcards inscript-srcorframe-src.
- Session pages (
- Headers (
securityHeaderEntries): nosniff,Referrer-Policy: strict-origin-when-cross-origin,X-Frame-Options: DENY+frame-ancestors 'none', a restrictivePermissions-Policy, COOPsame-origin, HSTS 2 years in production. After a header change, check the site on Mozilla Observatory (target A or better). NOINDEX_PATHSin the proxy sendX-Robots-Tag: noindex, nofollow.
Abuse protection
- Every public endpoint that writes or sends email has a rate limit (
allowRequest/allowEmail,atlas-backend): today email code, email verify, newsletter, volunteer, search. - Public forms (newsletter, volunteer) also pass Cloudflare Turnstile: widget
components/ui/turnstile.tsx, server checkpassesCaptcha(formData, headers)(lib/security/turnstile.ts) after Zod, plus a hidden honeypot field (website). Email sign-in hands the token to Supabase Auth ascaptchaToken, which verifies it. captchaEnabled()is read at build time, so both Turnstile keys are set indeploy.ymlas build env and runtime env.
Secrets and configuration
- Secrets live only in GitHub
Secrets /
Variables
and in gitignored
.env*.local.deploy.ymlpasses runtime secrets with--envand public values with--build-env. - A new secret: add it to the Zod schema in
lib/env.server.ts, an empty line in.env.example, the GitHub secret, and thedeploy.ymlargs. Read it only throughserverEnvin aserver-onlymodule. - Never prefix a secret with
NEXT_PUBLIC_, print one in logs or tool output, or commit one. - Tests, previews of migrations and experiments use atlas-dev credentials only.
GitHub Actions
- Top-level
permissions: contents: read; widen per job only if needed. actions/checkoutwithpersist-credentials: false.- Third-party actions that run next to production credentials are pinned to a full commit SHA
(
supabase/setup-cli@45a5...); CLIs are installed at exact versions (vercel@62.5.0, Supabase CLI2.120.0). - Secrets go in the
env:of the one step that needs them, not the job. - Never interpolate
${{ github.event.* }}text (titles, branch names) insiderun:; pass it throughenv:. Nopull_request_target. workflow_runand dispatch jobs check event, repository and branch explicitly (copy theif:ofdeploy.yml). Fork and Dependabot PRs never get secrets.- Artifacts that may hold sessions (Playwright reports) keep
retention-days: 1; traces stay off in CI.
Dependencies
- Dependabot (
.github/dependabot.yml) opens grouped PRs every Monday. Majors ofnext,eslint-config-next,maplibre-gl,typescriptandeslintare planned migrations, not bumps. npm run check:audit(audit-ci.jsonc: high and above in production dependencies) blocks CI. An allowlist entry needs a reason and an expiry plan.- Keep code scanning (CodeQL default setup), secret scanning with push protection and Dependabot alerts on.
- A new dependency is justified in the PR (size, maintenance, licence).
Uploads
- Allowed kinds live in
lib/upload-rules.ts: images JPEG/PNG/WebP/AVIF up to 3 MB, audio up to 50 MB. No SVG. The bucket enforces type and size again. prepareUpload(features/uploads/actions.ts) signs a one-object upload to{bucket}/{user_id}/{uuid}.{ext}under the session; Storage policies check the right. The browser never gets the session; names are server-generated.
Logging
- Server logs use a
[scope]prefix (console.error("[db]", ...)). Log the code and the message, never tokens, OTP codes, keys, passwords or full email addresses. mapDbErrorlogs unknown DB errors and returns a safe sentence; never send SQL or stack traces to the client.- Privileged changes are audited in the DB by
audit_row()triggers intoaudit_log(viewed at Admin → Roles → audit). Audit rows hold ids, not personal text, and are kept 12 months.
Personal data (GDPR)
- Minimise. Collect only what a feature needs. No IP addresses or user agents in tables (rate limits store HMACs). Card data never touches Atlas (Stripe hosts it).
- Retention: state it on the privacy page (Admin → Site pages → Privacy) and enforce it in
db_housekeeping(). A new kind of personal data needs a retention period in both places. - Export and deletion are self-service:
GET /api/account/export(apiRoutes.accountExport, RPCmy_personal_data()) anddeleteAccount(features/auth/actions.ts, RPCdelete_my_personal_data()), on/accountfor readers and/admin/accountfor the team. A new table with personal data must be added to both and tosupabase/tests/gdpr-completeness.test.mjs. Export keys are only added, never renamed. - Analytics are cookie-less (Vercel Analytics in
src/app/layout.tsx). Adding any tracker that sets cookies or fingerprints needs owner approval and a consent mechanism first. - Newsletter: explicit consent checkbox + double opt-in (
status: "pending"infeatures/newsletter/actions.ts), Mailchimp holds the list. - Processors (Supabase, Vercel, Mailchimp, Cloudflare, Google sign-in...) are listed on the privacy page. A new processor updates that list in the same PR (a guarded content migration).
- Requests by email (no account) are answered within 30 days, as the privacy page promises.
Checklist
- [ ] Every new action: Zod +
signedInwrapper + session client +affected. - [ ] No new
dangerouslySetInnerHTML, rawhref={data.url}or unchecked redirect. - [ ] New external origin:
csp.ts+csp.test.ts; embed provider also inlib/embeds.ts+ DBCHECK. - [ ] Public write or email endpoint: rate limit + Turnstile + honeypot.
- [ ] New secret:
env.server.ts,.env.example, GitHub secret,deploy.yml; noNEXT_PUBLIC_. - [ ] Workflow change: minimal permissions, pinned actions near secrets, step-level secrets.
- [ ] Personal data: retention, export, deletion, privacy page and GDPR test updated.
- [ ] PR "Security impact" filled in for code-owned paths.
Anti-patterns
- Hiding a button instead of denying in RLS: the action is still callable.
forbidden()/authInterruptsfor no-access pages: makes every page dynamic.- A nonce on public pages "for safety": ends static rendering for the whole site.
frame-src https:or a wildcard to make one embed work: use the allowlist.- Logging a whole request, user or error object that may contain tokens or emails.
Sources
- Next.js: Data security - https://nextjs.org/docs/app/guides/data-security
- Next.js: Content Security Policy - https://nextjs.org/docs/app/guides/content-security-policy
- OWASP Top 10 - https://owasp.org/Top10/
- OWASP Cheat Sheets: XSS, SSRF, File upload, Logging, Secrets - https://cheatsheetseries.owasp.org/
- Supabase: Auth rate limits - https://supabase.com/docs/guides/auth/rate-limits
- Supabase: API keys - https://supabase.com/docs/guides/api/api-keys
- GitHub: Security hardening for Actions - https://docs.github.com/en/actions/security-for-github-actions/security-guides/security-hardening-for-github-actions
- MDN HTTP Observatory scoring - https://developer.mozilla.org/en-US/observatory/docs/tests_and_scoring
- ICO: Data protection principles - https://ico.org.uk/for-organisations/uk-gdpr-guidance-and-resources/data-protection-principles/a-guide-to-the-data-protection-principles/
- GDPR Art. 12 and 33 - https://gdpr-info.eu/art-12-gdpr/ , https://gdpr-info.eu/art-33-gdpr/
- CNIL: Analytics exemption - https://www.cnil.fr/en/sheet-ndeg16-use-analytics-your-websites-and-applications
Files of this skill
reference.md
Security and privacy reference
Longer detail for atlas-security. Names only; never write a secret value anywhere.
Where configuration lives
| Kind | Place | Used by |
|---|---|---|
| GitHub secrets | Actions secrets | SUPABASE_ACCESS_TOKEN, SUPABASE_DB_PASSWORD (migrations, backup, migrate-dev), VERCEL_TOKEN, RATE_LIMIT_SECRET, TURNSTILE_SECRET_KEY (deploy), SUPABASE_SERVICE_ROLE_KEY_DEV (e2e and imports against atlas-dev) |
| GitHub variables | Actions variables | SUPABASE_PROJECT_REF, SUPABASE_DEV_PROJECT_REF, VERCEL_ORG_ID, VERCEL_PROJECT_ID, NEXT_PUBLIC_TURNSTILE_SITE_KEY, NEXT_PUBLIC_SUPABASE_URL_DEV, NEXT_PUBLIC_SUPABASE_ANON_KEY_DEV, SITE_URL, BACKUP_AGE_RECIPIENT |
| Vercel project env | Environment variables | Supabase URL and keys for production, Mailchimp, IndexNow, search-console tokens |
| Local | .env.local, .env.deploy.local (gitignored) | Development against atlas-dev; owner tooling |
| Template | .env.example (committed, empty values) | Lists every variable the app reads |
Schema of server variables: src/lib/env.server.ts (Zod). Public ones: src/lib/env.ts.
Key leak runbook (any token, key or password)
- Revoke or rotate the key at its source right away: Supabase API keys, Supabase account tokens, Vercel tokens, Cloudflare Turnstile, Mailchimp API keys (Account → Extras → API keys).
- Swap it in (GitHub secret or Vercel env) and redeploy through the normal pipeline (re-run the latest Deploy to Vercel run from main).
- Verify the site (
/api/health, sign-in, a form) and only then delete the old key. - Check logs for use of the old key: Supabase logs, Vercel logs,
audit_log. - Assess the breach. If personal data may have been exposed, the 72-hour GDPR notification clock starts when you become aware. Record every breach (facts, effects, remedy), even when no notification is needed.
- If the leak was a commit: removing it from history does not un-leak it. Rotation is the fix.
Rotating RATE_LIMIT_SECRET simply starts fresh rate-limit windows. When rotating Supabase keys,
prefer the newer publishable / secret key types over the legacy anon / service_role JWT keys.
Personal data map
| Data | Where | Why | Kept | Export | Delete |
|---|---|---|---|---|---|
| Account (email, name, role) | Supabase Auth, profiles | Sign-in, team work | While the account exists | yes | yes |
| Team assignments, invitations | approver_*, invitations | Permissions | Accepted invitations removed with the account | yes | yes |
| Volunteer applications | volunteer_applications | Recruiting writers | Until no longer needed, or on request (privacy page) | yes (by verified email) | yes |
| Newsletter address | Mailchimp | Newsletter | Until unsubscribe | Mailchimp | Mailchimp |
| Audit trail | audit_log | Accountability for privileged changes | 12 months (purge_audit_log) | own actions, column names only | pseudonymised, not deleted |
| Rate-limit windows | rate_limits (HMAC keys) | Abuse protection | 1 day | n/a | automatic |
| Preview links | preview_links (token hash only) | Sharing drafts | 7 days after expiry | n/a | automatic |
| Analytics | Vercel Analytics | Audience numbers | Vercel's retention | n/a (no personal data) | n/a |
When you add a row to this table in reality (a new table or a new processor), update the privacy
page, my_personal_data(), delete_my_personal_data() and gdpr-completeness.test.mjs in the same PR.
CSP: adding a third-party service
- Decide the directive: scripts (
script-src), API calls (connect-src), iframes (frame-src, vialib/embeds.tsfor content embeds), images (img-srcalready allows https), form posts (form-action). - Add the exact origin as a named constant in
lib/security/csp.tswith a comment saying why. - Add or extend a case in
src/lib/security/csp.test.ts(100 % coverage is required forsrc/lib/security/**). - Check both policies: a static page (
/topics) and a nonce page (/login) in the browser console. - If it collects personal data, it is a processor: update the privacy page.
Review prompts (OWASP Top 10 lens)
- Access control: can a reader, or a team member without the section right, reach the action or the row? Is there a denied test?
- Misconfiguration: new header, CSP origin, env var or workflow permission justified?
- Supply chain: new dependency or action pinned and justified?
- Injection: any string concatenated into SQL, HTML or a URL?
- Exceptional conditions: does any
catchturn a failed permission or DB read into "allowed" or "empty"? (must()throws for this reason.) - Logging: is the change auditable, and does any log line contain personal data?