In short
- AI-built apps break in the same places: keys in the browser bundle, tables without RLS, and routes that never check who owns a record.
- Run deterministic scanners for secrets, dependencies and code first, then an agent review, then read the auth and payment code yourself.
- Put Cloudflare in front, set security headers, test from outside, and add uptime, error and spend alerts before the first user signs up.
On this page
- The failure patterns to fix first
- Pre-launch review: scanners first, then agents, then you
- Environments and secrets
- Choosing where to deploy
- If you run your own server
- Put Cloudflare in front
- Security headers and cookies
- After deploy: test from outside
- Monitoring and alerts
- Backups and a simple incident plan
- Launch checklist
Building an app quickly with Claude Code, Codex, Cursor, Lovable, Bolt, v0, Replit Agent or a similar tool is one step for founders, indie hackers and developers. Putting real users on it without leaking keys, data or money is the next. You get, in launch order, the failure patterns that keep turning up in AI-generated apps and how to check each one, the review tools to run before launch, a deployment path with Cloudflare in front, the checks to run once it is live, and a launch checklist to copy.
I take apps like this live and harden them for clients through NoDowntime Technologies. The tools build features fast. Deciding who may see which record, where the keys live and what a stranger can do with a thousand requests a minute is still your job.
The failure patterns to fix first
Most of these map to the OWASP Top 10:2025 (A01 Broken Access Control, A02 Security Misconfiguration, A03 Software Supply Chain Failures) and the OWASP API Security Top 10 (API1 Broken Object Level Authorization, API4 Unrestricted Resource Consumption).
Secrets in the browser bundle or the repo
Next.js inlines any variable prefixed NEXT_PUBLIC_ into the JavaScript sent to the browser at build time. Vite does the same with VITE_, and its docs say those variables should not contain API keys. The browser may hold publishable keys only. Supabase’s secret key (and the legacy service_role key) bypasses Row Level Security and must never reach a browser, a shipped app or source control.
# Any tracked .env file is a leak, even if .gitignore lists it now
git ls-files | grep -iE '(^|/)\.env'
# Every variable name that reaches the browser: read the list
grep -rhoE '(NEXT_PUBLIC|VITE)_[A-Z0-9_]+' --exclude-dir=node_modules --exclude-dir=.git . | sort -u
Supabase tables without RLS, Firebase rules left open
Supabase’s docs are blunt: “A table in an exposed schema without RLS is readable and writable by any role with a grant on it.” Its Security Advisor flags this as an error (“Table publicly accessible”), and supabase db advisors runs the same checks from the CLI. Find and fix:
-- Tables in the public schema with RLS off
select tablename from pg_tables
where schemaname = 'public' and rowsecurity = false;
alter table public.invoices enable row level security;
create policy "Owners can read their invoices"
on public.invoices for select
to authenticated
using ((select auth.uid()) = user_id);
On Firebase, read the rules before launch. Firebase says never to use allow read, write: if true; in production, test mode grants access to all users, and if request.auth.uid != null lets any signed-in user read and write the whole database. Compare request.auth.uid with the document’s owner instead.
Public storage buckets
A public Supabase bucket lets anyone with a file’s URL download it; private buckets go through RLS or time-limited signed URLs. New S3 buckets do not allow public access by default, and AWS recommends keeping all four Block Public Access settings on. Invoices, ID scans and other user uploads belong in a private bucket.
Routes that never check ownership (IDOR)
OWASP describes it as “viewing or editing someone else’s account by providing its unique identifier”. The usual shape: a handler checks a session exists, then loads whatever ID arrived in the URL:
// Before: any signed-in user can read any invoice by changing the ID
const invoice = await db.invoice.findUnique({ where: { id } });
// After: the query itself is scoped to the owner
const invoice = await db.invoice.findFirst({
where: { id, ownerId: session.user.id },
});
if (!invoice) return new Response("Not found", { status: 404 });
To check, create two test accounts. Signed in as the second, request the first account’s IDs on every GET, PATCH and DELETE route, and call admin routes as a normal user (API5, Broken Function Level Authorization). Anything other than 403 or 404 is a bug.
No rate limits, and unbounded AI spend
API4 warns that requests not limited in compute or paid third-party services “can enable denial of service or drive up costs”. Rate limit sign-up, login, password reset, OTP, checkout and every route that calls a model, per user and per IP, and cap input size and output tokens on AI routes.
Then set a ceiling at the provider. On the OpenAI platform, open the project’s Limits page, edit the monthly spend limit and turn on Enforce a hard limit; without it, alerts only notify. Over the limit, requests return a 429 (project_spend_limit_exceeded), though spend can slightly overshoot. In the Claude Console, set a monthly limit and email alerts under Spend limits on the Workspaces page; the Default Workspace cannot take one, so create a workspace per app and environment.
Permissive CORS
Per MDN, the wildcard * works only for requests without credentials, so some code reflects any Origin instead, which is just as open. Check an allow-list, echo the matched origin and send Vary: Origin.
curl -sI -H "Origin: https://attacker.example" https://api.example.com/api/me | grep -i access-control
If that echoes attacker.example with Access-Control-Allow-Credentials: true, fix it.
Debug mode, stack traces and default credentials
OWASP A02 lists error handling that “reveals stack traces or other overly informative error messages” and default accounts “still enabled and unchanged”. Django says never to deploy with DEBUG on; Flask’s debugger “allows executing arbitrary Python code from the browser”. Remove seeded admin users and demo passwords, and change every default password.
Hallucinated and typosquatted packages
A USENIX Security 2025 paper by Spracklen and colleagues tested 16 code-generating models across 576,000 samples. On average at least 5.2% of packages from commercial models and 21.7% from open-source models did not exist: 205,474 unique invented names. Anyone can register one and wait, an attack now known as slopsquatting. Before installing a package an agent added, confirm it is real:
npm view PACKAGE_NAME repository.url time.created
A missing repository, a creation date from last week or a name one letter off a popular package means stop. Commit the lockfile and install with npm ci in CI.
Pre-launch review: scanners first, then agents, then you
Deterministic scanners
These give the same answer every run, so they can gate CI. The dependency scanning comparison and the SAST tools guide cover the choices in depth.
# Secrets in git history and in the working tree
gitleaks git -v
gitleaks dir -v .
# TruffleHog checks whether found keys are live (run from the parent directory)
trufflehog git file://my-app --results=verified
# Known-vulnerable dependencies
npm audit --audit-level=high
osv-scanner scan source -r .
# Static analysis; --error exits 1 on findings
semgrep scan --config auto --error
--config auto fetches rules from the Semgrep Registry and sends your project URL to do it. On GitHub, CodeQL default setup is under Settings > Advanced Security > CodeQL analysis > Set up > Default, for public repositories or those with GitHub Code Security.
For performance and accessibility, run Lighthouse (npm install -g lighthouse, Node 22 or later) against staging, then write one Playwright smoke test per critical path: sign-up, login, payment and the main feature.
// tests/smoke.spec.ts
import { test, expect } from "@playwright/test";
const BASE = process.env.BASE_URL ?? "http://localhost:3000";
test("sign up and reach the dashboard", async ({ page }) => {
await page.goto(`${BASE}/signup`);
await page.getByLabel("Email").fill(`smoke+${Date.now()}@example.com`);
await page.getByLabel("Password").fill(process.env.SMOKE_PASSWORD ?? "");
await page.getByRole("button", { name: "Create account" }).click();
await expect(page).toHaveURL(/dashboard/);
});
npm init playwright@latest
BASE_URL=https://staging.example.com npx playwright test
Test payments on staging in a Stripe sandbox with test keys and card 4242 4242 4242 4242; test cards do not work in live mode.
Agent review in Claude Code and Codex
In Claude Code, two review commands are built in:
/security-review
/code-review
/security-review reviews the diff between your branch and origin’s default branch for injection, auth issues and data exposure, so it covers your branch’s changes, not code already on main. /code-review checks the current diff for correctness bugs; --fix applies the findings. For more depth, install from Anthropic’s official marketplace:
/plugin install security-guidance@claude-plugins-official
/plugin install claude-security@claude-plugins-official
/claude-security scan my branch
The security guidance plugin reviews Claude’s own edits as it writes them. The Claude Security plugin scans a whole repository or a diff with independent verification of each finding, and needs a paid plan or API access. Trail of Bits publishes review plugins for both agents, such as insecure-defaults (fail-open configuration) and differential-review:
/plugin marketplace add trailofbits/skills
/plugin install insecure-defaults@trailofbits
In Codex, type /review in a session, or run it from the shell:
codex review --base main
codex plugin marketplace add trailofbits/skills
codex plugin add insecure-defaults@trailofbits
Cloudflare’s security audit skill installs into any agent the Skills CLI supports; then ask the agent to “security audit this codebase”:
npx skills add https://github.com/cloudflare/security-audit-skill --skill security-audit
Read any third-party skill before installing it; security review skills for AI coding agents covers vetting. Agent reviews complement the scanners and never replace them: Anthropic’s Claude Security docs say two scans of the same code can surface different findings, and that the plugin does not replace static analysis or dependency scanning.
Then read the risky code yourself
Read these line by line: session and auth middleware, every route that takes an ID, the payment webhook (signature verified, amount taken from the server, not the request), password reset, file upload and admin routes.
Environments and secrets
- Three environments. Development, staging and production each get their own database and API keys; only production holds live Stripe keys.
- Secrets live in the platform’s secret store or server environment variables. Keep
.envfiles out of git; Next.js’s starter template already ignores them. - Rotate any key that was ever committed or pasted into a chat. GitHub’s own guidance makes revoking or rotating the first step, because clones and forks keep copies even after history is rewritten.
- Least privilege. The app’s database user should not own the schema or be a superuser; migrations run as a separate user. Give each environment its own AI provider key and spend limit.
Choosing where to deploy
Checked on 11 November 2026, on each vendor’s own page.
| Option | Good fit | Free tier notes |
|---|---|---|
| Vercel | Next.js and front ends with functions | Hobby is free but limited to non-commercial, personal use; spend management is a Pro feature |
| Netlify | Static and front-end apps with functions | Free plan lists a 300 credit limit |
| Cloudflare Workers and Pages | Static sites and APIs at the edge | 100,000 Worker requests a day, 10 ms CPU per invocation; static asset requests free and unlimited |
| Render | Web services, workers, Postgres | Free web services spin down after 15 idle minutes; free Postgres expires after 30 days; Render says not for production |
| Fly.io | Containers close to users | No free tier for new organisations; a trial with $1 of credit for 7 days |
| Railway | Repo-to-service deploys with databases | One-time $5 trial credit; Hobby is $5 a month including $5 of usage |
| Your own VPS | Long-running workers, websockets, fixed monthly cost | You own patching, firewall, TLS, backups and monitoring |
With no one on call for servers, choose a managed platform; choose a VPS for processes a platform will not run, or a predictable bill at steady load. Hosting a site free on Cloudflare Workers walks through the cheapest managed path.
If you run your own server
The baseline, in order:
- A non-root user with sudo; SSH keys only, root login and passwords off.
- A firewall that denies inbound by default and allows only 22, 80 and 443 (or only Cloudflare’s ranges, below).
- Automatic security updates, and fail2ban or CrowdSec on SSH.
- The app runs as its own unprivileged user under systemd, listening on
127.0.0.1, behind nginx or Caddy. - The database listens on localhost or a private network, never the public internet.
- Backups copied off the server, restored once to prove they work.
# /etc/systemd/system/my-app.service
[Unit]
Description=my-app
After=network.target
[Service]
User=myapp
WorkingDirectory=/srv/my-app
EnvironmentFile=/etc/my-app/env
ExecStart=/usr/bin/node server.js
Restart=on-failure
NoNewPrivileges=true
[Install]
WantedBy=multi-user.target
The Linux server hardening checklist has the exact commands for each step on Ubuntu 24.04, including how to test SSH changes before you log out.
Put Cloudflare in front
- Move DNS to Cloudflare and set web records to Proxied (orange cloud), which hides your origin. DNS-only records expose it and get none of Cloudflare’s security features.
- SSL/TLS: Full (strict). It validates the origin certificate, which must be publicly trusted or from Cloudflare’s Origin CA.
- Always Use HTTPS on. Cloudflare advises against also redirecting at the origin, which can loop.
- HSTS under Edge Certificates, once every subdomain serves HTTPS, without preload at first. Afterwards, do not pause Cloudflare, switch records to DNS-only or turn SSL off: if HTTPS breaks, visitors are locked out until the max-age runs out.
- WAF: turn on the Cloudflare Free Managed Ruleset (available on all plans) and use your one Free-plan rate limiting rule on login. It counts by IP over 10 seconds and matches only path and verified-bot status:
(http.request.uri.path eq "/api/auth/login"). - Bot Fight Mode cannot be skipped by WAF custom rules, “may challenge API or mobile app traffic”, and needs CSP changes for its JavaScript detections. Leave it off for apps that serve an API or a mobile client.
- Lock the origin. Allow 443 only from Cloudflare’s ranges, or use Cloudflare Tunnel, where
cloudflaredconnects outbound only and the origin needs no public IP. Mail on the web server and DNS-only records both leak the origin IP. - Turnstile on sign-up and password reset: free for up to 20 widgets with unlimited challenges. Validate every token server-side with Siteverify; tokens last 300 seconds and are single use.
for cidr in $(curl -fsS https://www.cloudflare.com/ips-v4) $(curl -fsS https://www.cloudflare.com/ips-v6); do
sudo ufw allow proto tcp from "$cidr" to any port 443 comment 'Cloudflare'
done
sudo ufw delete allow 80/tcp
sudo ufw delete allow 443/tcp
Plan-by-plan detail is in Cloudflare security and performance settings by plan.
Security headers and cookies
Set headers once, at Cloudflare or the origin. Values follow the OWASP HTTP Headers Cheat Sheet, except HSTS: OWASP suggests two years with preload, and one year without preload is a safer start. Drop that line if Cloudflare already sends HSTS.
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header Content-Security-Policy "default-src 'self'; object-src 'none'; base-uri 'self'; frame-ancestors 'none'" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "geolocation=(), camera=(), microphone=()" always;
frame-ancestors 'none' stops other sites framing your app. A real app’s CSP needs more sources than this, so ship it first as Content-Security-Policy-Report-Only, which reports violations without enforcing, then switch. OWASP also recommends removing X-Powered-By and making Server uninformative.
Session cookies:
Set-Cookie: __Host-session=YOUR_SESSION_ID; Path=/; Secure; HttpOnly; SameSite=Lax
Secure sends it only over HTTPS, HttpOnly hides it from JavaScript, and SameSite=Lax keeps it off cross-site POST requests. The __Host- prefix makes the browser reject the cookie unless it has Secure, Path=/ and no Domain.
After deploy: test from outside
DOMAIN=app.example.com
# HTTP redirects to HTTPS; security headers present; no version banners
curl -sI "http://$DOMAIN" | head -n 3
curl -sI "https://$DOMAIN" | grep -iE 'strict-transport|content-security|x-content-type|referrer-policy|set-cookie|server|x-powered-by'
# Files that must never be served: expect 404 or 403
for p in .env .git/config; do curl -s -o /dev/null -w "%{http_code} /$p\n" "https://$DOMAIN/$p"; done
# Error pages: no stack traces, file paths or framework banners
curl -s "https://$DOMAIN/this-page-does-not-exist" | head -n 20
curl -s -X POST "https://$DOMAIN/api/YOUR_ROUTE" -H 'Content-Type: application/json' -d '{bad json' | head -n 20
# If the site is public
curl -s "https://$DOMAIN/robots.txt"
Confirm no server secret reached the client bundle. Run this in bash after a production build, with the production variables loaded:
gitleaks dir -v .next/static # or dist/ for Vite
for name in DATABASE_URL STRIPE_SECRET_KEY OPENAI_API_KEY SUPABASE_SECRET_KEY; do
value="${!name:-}"
[ -n "$value" ] && grep -rqF -- "$value" .next/static dist 2>/dev/null && echo "LEAK: $name is in the client bundle"
done
Then the external tests:
- SSL Labs Server Test for the TLS setup; tick “Do not show the results on the boards”.
- Mozilla HTTP Observatory on MDN for headers and related settings.
- PageSpeed Insights on the live URL: lab data from Lighthouse, plus field data from the Chrome UX Report once you have traffic.
- ZAP baseline scan, only against your own app. It spiders for a minute and scans passively, without attacks:
docker run -v "$(pwd)":/zap/wrk/:rw -t ghcr.io/zaproxy/zaproxy:stable \
zap-baseline.py -t "https://$DOMAIN" -r zap-report.html
Monitoring and alerts
Check from outside, on the home page, the login page and a /health route that touches the database. Free tiers, checked on 11 November 2026:
| Service | Free tier |
|---|---|
| UptimeRobot | 50 monitors at a 5-minute interval, personal and commercial use; SMS and voice credits not included |
| Better Stack | 10 monitors and heartbeats, 1 status page, Slack and email alerts; aimed at personal projects |
| Cloudflare Health Checks | Not on Free; 10 on Pro, 50 on Business |
| New Relic | 500 synthetic checks a month; simple ping monitors do not count |
| Sentry (Developer) | 1 user, 5k errors, 1 uptime monitor, 1 cron monitor |
Send the critical alert somewhere that wakes you, not only email. Add error tracking with personal data scrubbed, and ship app and proxy logs off the server; the New Relic free tier guide sets up checks and alerts end to end. Then add:
- Certificate expiry for the origin certificate and any hostname not behind Cloudflare.
- Domain expiry: auto-renew on, with a card that will still be valid.
- Billing: the AI provider limits above, plus a cloud budget. AWS Budgets alerts on actual and forecast spend by email or SNS, though its data can lag by several hours. The AWS cost optimisation checklist covers the rest.
Backups and a simple incident plan
pg_dump --format=custom --file="backup-$(date +%F).dump" "$DATABASE_URL"
createdb restore_test
pg_restore --dbname=restore_test --no-owner "backup-$(date +%F).dump"
- Backups run automatically, sit outside the server and the hosting account, and are restored into a scratch database monthly.
- Paging: one named person and one backup, with phone numbers in the alerting tool.
- Rollback: know the button or command that redeploys the previous version, and use it once before launch.
- Key rotation: one list of every key, where it is used and how to rotate it.
- Compromise: if a server may be breached, follow the compromised Linux server first-hour runbook before touching anything.
Launch checklist
Before launch
- No secrets in git history or in
NEXT_PUBLIC_andVITE_variables - RLS on every exposed Supabase table, or Firebase rules scoped to owners; buckets private
- Ownership checked on every route, tested with two accounts
- Rate limits on auth, AI and payment routes; hard spend limits at each AI provider
- CORS allow-list, debug off, demo accounts and default passwords gone
- Every new package confirmed real; lockfile committed
- Secret, dependency and SAST scans clean
- Agent review run; auth and payment code read by a person
- Playwright smoke tests pass on staging
Deploy
- Separate dev, staging and production keys
- Every key ever committed or pasted into a chat rotated
- Server: non-root, keys only, firewall, auto-updates, app unprivileged, database private
- Cloudflare: proxied, Full (strict), Always Use HTTPS, HSTS, WAF, rate limit, origin locked
- Turnstile validated server-side
- Security headers and
Secure; HttpOnly; SameSitesession cookies
After launch
- curl checks pass: redirect, headers, no
.env, clean error pages - No server secret in the built bundle
- SSL Labs, HTTP Observatory, PageSpeed Insights and ZAP baseline run
- Smoke test passes on production
Ongoing
- Uptime, error, certificate, domain and billing alerts reach a phone
- Backups restored monthly; scanners in CI on every pull request
- Rollback and key rotation tried once
Sources
- OWASP Top 10:2025 top10.owasp.org
- A01:2025 Broken Access Control (OWASP Top 10) top10.owasp.org
- A02:2025 Security Misconfiguration (OWASP Top 10) top10.owasp.org
- OWASP API Security Top 10 2023 api-security.owasp.org
- HTTP Security Response Headers Cheat Sheet (OWASP) cheatsheetseries.owasp.org
- How to use environment variables in Next.js (Next.js docs) nextjs.org
- Env variables and modes (Vite docs) vite.dev
- Row Level Security (Supabase docs) supabase.com
- Understanding API keys (Supabase docs) supabase.com
- Database advisors (Supabase docs) supabase.com
- Storage buckets (Supabase docs) supabase.com
- Avoid insecure rules (Firebase Security Rules docs) firebase.google.com
- Blocking public access to your Amazon S3 storage (AWS docs) docs.aws.amazon.com
- Access-Control-Allow-Origin (MDN) developer.mozilla.org
- Debugging application errors (Flask docs) flask.palletsprojects.com
- Django settings: DEBUG (Django docs) docs.djangoproject.com
- We Have a Package for You! A Comprehensive Analysis of Package Hallucinations by Code Generating LLMs (arXiv, USENIX Security 2025) arxiv.org
- Spend limits (OpenAI API docs) developers.openai.com
- Workspaces (Claude Platform docs) platform.claude.com
- Commands (Claude Code docs) code.claude.com
- Catch security issues as Claude writes code (Claude Code docs) code.claude.com
- Scan your codebase for vulnerabilities (Claude Code docs) code.claude.com
- trailofbits/skills (GitHub) github.com
- cloudflare/security-audit-skill (GitHub) github.com
- Developer commands (OpenAI Codex docs) learn.chatgpt.com
- gitleaks/gitleaks (GitHub) github.com
- trufflesecurity/trufflehog (GitHub) github.com
- google/osv-scanner (GitHub) github.com
- npm audit (npm docs) docs.npmjs.com
- CLI reference (Semgrep docs) docs.semgrep.dev
- Configuring default setup for code scanning (GitHub docs) docs.github.com
- GoogleChrome/lighthouse (GitHub) github.com
- About PageSpeed Insights (Google for Developers) developers.google.com
- Installation (Playwright docs) playwright.dev
- Testing (Stripe docs) docs.stripe.com
- Removing sensitive data from a repository (GitHub docs) docs.github.com
- Vercel Hobby plan (Vercel docs) vercel.com
- Netlify pricing netlify.com
- Pricing (Cloudflare Workers docs) developers.cloudflare.com
- Deploy for free (Render docs) render.com
- Pricing (Fly.io docs) docs.fly.io
- Plans (Railway docs) docs.railway.com
- Proxy status (Cloudflare DNS docs) developers.cloudflare.com
- Full (strict) encryption mode (Cloudflare SSL/TLS docs) developers.cloudflare.com
- Always Use HTTPS (Cloudflare SSL/TLS docs) developers.cloudflare.com
- HTTP Strict Transport Security (Cloudflare SSL/TLS docs) developers.cloudflare.com
- Managed Rules (Cloudflare WAF docs) developers.cloudflare.com
- Rate limiting rules (Cloudflare WAF docs) developers.cloudflare.com
- Bot Fight Mode (Cloudflare bot solutions docs) developers.cloudflare.com
- Protect your origin server (Cloudflare Fundamentals docs) developers.cloudflare.com
- Cloudflare Tunnel (Cloudflare One docs) developers.cloudflare.com
- Validate the token (Cloudflare Turnstile docs) developers.cloudflare.com
- Turnstile plans (Cloudflare Turnstile docs) developers.cloudflare.com
- Set-Cookie (MDN) developer.mozilla.org
- Content-Security-Policy-Report-Only (MDN) developer.mozilla.org
- HTTP Observatory (MDN) developer.mozilla.org
- SSL Server Test (Qualys SSL Labs) ssllabs.com
- ZAP baseline scan (ZAP docs) zaproxy.org
- UptimeRobot pricing uptimerobot.com
- Better Stack pricing betterstack.com
- Health Checks (Cloudflare docs) developers.cloudflare.com
- Synthetics usage limits (New Relic docs) docs.newrelic.com
- Sentry pricing sentry.io
- Managing your costs with AWS Budgets (AWS docs) docs.aws.amazon.com