Skip to main content

Go-Live Security Checklist

Everything you must configure before putting DirectoryLaunch in front of real users — secrets, RLS, Supabase Auth protection, storage, DNS and email authentication.

The boilerplate ships with the code-level defences already in place. What it cannot ship is the configuration of your Supabase project, your domain and your environment variables. This page is the list of things only you can do.

Work through it once before your first real user, then again before you point a domain at it.

Start with the secret

Do not skip step 1. The app is designed to refuse to start without CRON_SECRET, because that secret authenticates the admin and cron APIs. If you see a startup error mentioning it, that is the guard working as intended.

1. Generate your secrets

openssl rand -hex 32

Set the result as CRON_SECRET in Vercel → Settings → Environment Variables (Production and Preview). It must be at least 32 characters.

VariableRequired whenWhat it protects
CRON_SECRETalwaysAdmin API + cron endpoints. Also signs unsubscribe links unless UNSUBSCRIBE_SECRET is set.
SUPABASE_SECRET_KEYalwaysServer-side database access. Never expose with a NEXT_PUBLIC_ prefix.
STRIPE_WEBHOOK_SECRETwhenever STRIPE_SECRET_KEY is setVerifies that payment events really came from Stripe.
TELEGRAM_WEBHOOK_SECRETwhenever TELEGRAM_BOT_TOKEN is setThe Telegram webhook is refused without it.
PRINTFUL_WEBHOOK_SECRETwhenever PRINTFUL_API_KEY is setThe Printful webhook is refused without it.

Rotate every key away from any value that was shared with you, appeared in a tutorial, or was ever pasted into a chat.

2. Apply the schema, then verify RLS actually landed

Run supabase/schema.sql and every file in supabase/migrations/ in the Supabase SQL Editor, in order. Then verify — do not assume:

select c.relname as table_name, c.relrowsecurity as rls_enabled,
       (select count(*) from pg_policies p where p.tablename = c.relname) as policies
from pg_class c join pg_namespace n on n.oid = c.relnamespace
where n.nspname = 'public' and c.relkind = 'r'
order by c.relrowsecurity, c.relname;

Every row must show rls_enabled = true. A table with RLS off is readable by anyone holding your publishable key — and that key ships in your client bundle by design.

Then check what the anonymous role can reach:

select table_name, privilege_type
from information_schema.role_table_grants
where grantee = 'anon' and table_schema = 'public'
order by table_name;

Migration 0013_security_hardening.sql moves public listing reads to the apps_public view, which excludes contact_email, phone, submitted_by and address_exact. If you skip that migration, those columns are readable by anyone with the publishable key.

Finally, open Supabase → Advisors → Security Advisor and clear anything it flags.

3. Turn on Supabase Auth protection

The boilerplate uses Supabase Auth for user sign-in, so brute-force and credential-stuffing defence lives in the Supabase dashboard, not in the code.

Supabase → Authentication → Attack Protection:

  • Enable leaked password protection (checks against Have I Been Pwned).
  • Enable CAPTCHA (hCaptcha or Turnstile) on sign-in and sign-up.
  • Review Rate Limits — the defaults are generous; lower the sign-in and OTP limits.

Supabase → Authentication → Multi-Factor: enable MFA and turn it on for your own admin account.

Supabase → Authentication → URL Configuration: set the Site URL to your real domain and list only your own redirect URLs. A wildcard here turns password-reset emails into a token-theft vector.

4. Lock down storage

Supabase → Storage → your bucket → Policies. Uploads are validated server-side (magic bytes, MIME allowlist, size cap, server-generated filenames), but the bucket's own policy decides who can list and read it. Confirm the bucket is not publicly listable.

5. Grant admin correctly

The users table has two admin signals: role = 'admin' and is_admin = true. Set both:

update public.users set role = 'admin', is_admin = true where email = 'you@example.com';

Migration 0013 makes the RLS policies accept either signal, but keeping them in sync avoids a class of confusing bugs where the app and the database disagree about who is an admin.

6. Email authentication (DNS)

If you send transactional email, configure all three. Without them, anyone can send mail that looks like it came from your domain — and your own users are the target.

  • SPF — one v=spf1 record per hostname. If your mail provider uses a custom Return-Path on a subdomain (Resend does this with send.yourdomain.com), the provider's SPF belongs on that subdomain, not merged into your root record. Two v=spf1 records on the same name is an error; on different names it is normal and correct.
  • DKIM — add the selector your provider gives you. Multiple providers coexist fine, because each uses its own selector.
  • DMARC — start at v=DMARC1; p=none; rua=mailto:you@yourdomain.com, read the aggregate reports for a week, then move to p=quarantine and eventually p=reject. p=none enforces nothing.

Also add a CAA record so only your CA can issue certificates for the domain, and enable DNSSEC at your registrar if it offers it.

7. Demo mode must be off

NEXT_PUBLIC_APP_MODE=demo disables admin authentication so visitors can explore the panel. In production it is ignored unless you also set the server-only ALLOW_DEMO_IN_PRODUCTION=yes.

Only enable that pair on a throwaway project whose database contains seed data and nothing else. Never on the deployment that holds real users.

8. Repository hygiene

  • Enable secret scanning and push protection on your GitHub fork.
  • Confirm .env.local is not tracked: git ls-files | grep -i env should show only .env.example.
  • Never commit a .env file, and never paste a key into an issue or a chat.

9. Add a CI security gate

Create .github/workflows/security.yml so every push is checked:

name: security
on: [push, pull_request]
permissions:
  contents: read
jobs:
  audit:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: pnpm/action-setup@v4
      - uses: actions/setup-node@v4
        with: { node-version: 22, cache: pnpm }
      - run: pnpm install --frozen-lockfile
      - run: pnpm audit --audit-level=high
      - run: pnpm lint

10. Keep dependencies current

pnpm audit --audit-level=high
pnpm up next@latest

Next.js ships security fixes regularly, including middleware and SSRF advisories. Staying more than a couple of minor versions behind is a real risk, not a theoretical one.


Quick verification

Run these against your live deployment once it is up. Every one should fail or return nothing:

curl -s -o /dev/null -w "%{http_code}\n" https://yourdomain.com/.env          # expect 404
curl -s -o /dev/null -w "%{http_code}\n" https://yourdomain.com/.git/config   # expect 404
curl -sI https://yourdomain.com | grep -i content-security-policy             # expect a CSP
curl -s -H "Authorization: Bearer undefined" \
     https://yourdomain.com/api/admin?type=stats                              # expect 401
dig +short TXT _dmarc.yourdomain.com                                          # expect a DMARC record

The fourth one matters most: a 401 proves your CRON_SECRET guard is working. Anything else means the variable is unset.