You asked for this project to be "fully secure from being hacked by anyone at any point in time in its existence, ever." I want to be straight with you about that framing before anything else: no piece of software, built by anyone, at any company, at any budget, can honestly promise that. Security isn't a switch you flip once — it's a process you keep doing for as long as the app is running, because new vulnerabilities get discovered in every framework and library on a rolling basis, forever. Anyone who tells you their app is permanently unhackable is either wrong or selling something.
What I can do, and did do, is build this to current best practice and document exactly what that covers — so you know what's handled, what still depends on you (rotating keys, applying updates, configuring your host correctly), and what no app can fully rule out (a zero-day in a dependency, a compromised upstream package, a misconfigured server you deploy it to). That's a more useful promise than "unhackable," and it's the honest one.
- All sign-up/sign-in/session handling is delegated to Clerk, not hand-rolled. Passwords are never seen, stored, or hashed by this codebase — that removes an entire category of risk (weak hashing, leaked password tables, session-fixation bugs) by simply not owning it.
src/proxy.tsprotects every route except the public landing page and the auth pages themselves, at the network boundary, before any page component or API handler executes. This is Next.js 16's renamedmiddleware.ts— same mechanism, new name (see "Next.js 16, specifically" below for why the version matters here).- That single check is deliberately not the only check:
src/app/careers/page.tsxandsrc/app/simulation/page.tsxboth independently call Clerk'sauth()server-side and redirect if there's no session, and every API route checksauth()again itself. This redundancy is intentional — see "A real bug this caught," below.
- Every database query that touches user data filters by the
userIdClerk verified from the session — never a userId passed in the request body or a query string. One signed-in user structurally cannot read or write another user's rows; there's no code path where that value comes from anywhere but the verified session.
- This is a game, so "don't trust the client" also applies to the score
itself. The client sends the server the career id, the difficulty, and the
raw list of choice ids the player made — nothing else. Both
careeranddifficultyare validated against a fixed enum (z.enum(CAREER_IDS)/z.enum(difficulties)insrc/lib/validation.ts) derived directly from the engine's own career registry, so an unrecognized or malformed career id is rejected before it ever reaches the database or the replay logic — it can't be used to probe for a career that doesn't exist or smuggle in an arbitrary string. src/app/api/simulation/route.tsthen replays those choices through that specific career's scene graph server-side (src/lib/simulationEngine.ts) and computes the final stats and ending itself. A modified client could lie about which button it thinks the player pressed, but it cannot inject an arbitrary "I scored 100%" result — the server checks every choice id against what was actually valid at that point in that career's story and rejects anything that doesn't match.- This also means adding a new career (see README.md > "Adding a new career") never requires touching the validation or API layer — the allowed-career list, and therefore what the API will accept, is generated from the same registry the game itself reads from, so there's no separate allowlist that could drift out of sync.
/share/[runId] and /api/og/[runId] are allowlisted in proxy.ts as
public — no Clerk session required. This is intentional, not an oversight:
a shareable link needs to work for a logged-out visitor clicking it, and the
Open Graph image needs to work for a social media crawler that will never
have a session at all. Both routes fetch a SimulationRun by id with no
userId filter — by design, since the id itself isn't a secret and the
whole point is that it's shareable.
What keeps this from being a privacy problem: both routes only ever read
and return career, difficulty, endingKey, and the derived stats/score
— the same class of information already shown, un-gated, on the global
leaderboard to every signed-in user. Neither route touches userId or the
User table at all; there's no code path in either file that could leak
who played a given run, even though the run id itself is guessable-ish
(a cuid(), not sequential, but not cryptographically secret either — it's
treated as "unlisted," not "secret," the same way an unlisted YouTube video
works). If you ever add anything to SimulationRun that shouldn't be
public — notes, a display name, anything tied to identity — it needs to be
explicitly excluded from these two routes' queries, not just left out of
what's currently rendered.
- Every API route parses its input through a Zod schema
(
src/lib/validation.ts) before touching the database: types, ranges, and array lengths are all bounded. This blocks malformed payloads, oversized arrays meant to exhaust memory/storage, and injection of unexpected fields.
- All database access goes through Prisma's query builder. There is no string-concatenated or raw SQL anywhere in this codebase, which is the single biggest practical defense against SQL injection.
- React escapes all rendered text by default, and this codebase never uses
dangerouslySetInnerHTML. Combined with the CSP below, this closes off both the "attacker submits a script" and "attacker's script would've been allowed to run anyway" paths.
Applied to every response:
- Content-Security-Policy — restricts what scripts/frames/images are
allowed to load, scoped to
'self'plus Clerk's own domains. If you set up a custom Clerk domain for production (e.g.clerk.yourdomain.com, instead of the default*.clerk.accounts.dev), add it toscript-src/connect-src/frame-srcinnext.config.js— the CSP will otherwise silently block Clerk's own scripts on that domain and auth will break in a way that looks like a Clerk configuration problem rather than a CSP one. - X-Frame-Options: DENY /
frame-ancestors 'none'— prevents clickjacking (the app being embedded in a hidden iframe on someone else's malicious page). - X-Content-Type-Options: nosniff — stops the browser from MIME-sniffing a file into executing as something it isn't.
- Strict-Transport-Security — forces HTTPS once your host has TLS configured (see the deploy checklist below — the header alone doesn't add TLS, it just refuses to fall back to plain HTTP once it's there).
- Referrer-Policy and Permissions-Policy — reduce what leaks to other origins and disable camera/mic/geolocation the app never uses.
poweredByHeader: false— don't hand an attacker a free "this is a Next.js app" fingerprint.
- Every API route is rate-limited per authenticated user id, not per IP (IPs are shared and spoofable via headers). See the in-memory caveat below — this is the one place where "works today" and "works at scale" diverge, and it's called out explicitly rather than glossed over.
npm audit --omit=dev surfaced 4 findings, all recently disclosed (within
days of each other) rather than long-standing. Two were genuinely
fixable and are fixed as of this project state:
- Next.js core was pinned to an exact version (
"next": "16.2.10", no^), inconsistent with every other dependency inpackage.jsonand actively blocking security patches from installing normally. Switched to^16.2.10and updated to16.2.12, which resolved several disclosed CVEs (a middleware/proxy bypass, a few SSRF and cache-confusion issues, a Server Actions DoS). - postcss (a build-time CSS processing tool, pulled in by Tailwind)
had a since-patched path-traversal advisory
(
GHSA-r28c-9q8g-f849/CVE-2026-45623). Updated the directdevDependencyfrom^8.5.10to^8.5.18, the first patched release.
Two findings remain, and neither has an available fix as of this writing — here's the honest reasoning for shipping anyway rather than blocking on them indefinitely:
sharp(bundled by Next.js for its built-in Image Optimization API) has inheritedlibvipsCVEs with no patchedsharprelease yet. This app never importsnext/imageanywhere (confirmed by grepping the codebase) — the vulnerable code path is present innode_modulesbut never executed by anything this app's code calls.- Next.js's own internal, bundled copy of postcss (a second,
separate copy from the one in this project's own
devDependencies, vendored insidenext's package internals for Next's own build tooling) is still on the vulnerable version. This can't be safely forced to a newer version from a downstreampackage.json— attempting it (via a nestedoverridesentry) left npm reporting the installed copy asinvalid, meaning Next's internal code expects that exact version's API shape. The underlying vulnerability also specifically requires processing untrusted, attacker-supplied CSS at runtime (CMS themes, user-uploaded stylesheets) — Next's internal postcss usage processes its own build pipeline, not arbitrary runtime input, so the practical exposure here is low even though the finding is real.
What this means going forward, not just right now: re-run npm audit --omit=dev periodically, especially before major releases, and update
next (and re-check postcss) again once patched versions ship — this
isn't a one-time fix, it's an ongoing maintenance habit for any project
with real dependencies.
.env.examplecontains placeholders only, and is the only env file tracked in git..gitignoreexplicitly excludes.env,.env.local, and every real database file.- The AI narration key (
OPENAI_API_KEY) is read only in a server-side route handler and is never sent to, or readable by, the browser. - Prisma logging is configured to never log query parameters in production, since those can contain user-entered data.
.github/dependabot.ymlopens weekly automated PRs for vulnerable dependencies.npm run auditis wired up as a one-command check before you deploy.package.jsonpins apostcssoverride to^8.5.10. This isn't decorative: Next.js 16.2.10 (the current stable release at the time of writing) bundles an older, vulnerablepostcssinternally (GHSA-qx2v-qp2m-jg93/CVE-2026-41305, an XSS in CSS stringification). The override forces npm to use the patched version everywhere, including inside Next's own dependency tree.npm auditcame back clean with this in place; without it, it does not. If you ever remove this override, re-runnpm auditbefore assuming it's safe to do so.
This project targets Next.js 16 rather than 14 or 15 for a concrete reason,
not just "use the newest thing": Next.js 14 reached end-of-life in October
2025 and stopped receiving security patches entirely. A huge number of
Next.js tutorials, templates, and existing codebases are still on 14 — if
you're evaluating this against another AI-generated or templated project,
check its package.json for this. Building on an EOL framework version is
one of the most common, and most avoidable, security mistakes in web apps
right now. See "Keeping this current" in README.md for how this will need
to be revisited over time, because 16 will eventually reach the same point.
While building this, testing surfaced a live, documented issue: recent
@clerk/nextjs v7 releases running on Next.js 16's new proxy runtime have
had at least one reported case (clerk/javascript#8302) where
auth.protect() silently failed to redirect unauthenticated users away from
a protected page, under a specific environment-variable/monorepo condition —
meaning the proxy-layer check alone did not reliably block access. This is
exactly why src/app/careers/page.tsx and src/app/simulation/page.tsx each
independently re-check auth() and redirect themselves, rather than relying
solely on src/proxy.ts. That redundancy isn't defensive-programming
paranoia for its own sake — it's a direct response to a real, filed bug in
the exact stack this project uses. Keep both layers if you extend this app;
don't remove the page-level checks as "redundant" once proxy.ts looks like
it's working, because "looks like it's working" is precisely how that bug
manifested for other people.
The Content-Security-Policy in next.config.js originally had one
script-src for every environment, with no 'unsafe-eval'. That's the
right call for production — React never calls eval() in a production
build, so leaving unsafe-eval out entirely is a real hardening win against
script-injection attacks that rely on it. It broke local development,
though: React's Fast Refresh and its dev-mode debugging tooling (rebuilding
component stacks) do call eval(), and a strict CSP with no exception for
it produces exactly the browser console error a real person hit while
testing this locally ("eval() is not supported... make sure unsafe-eval is
included").
The fix makes the policy environment-aware rather than picking one setting
for both cases: 'unsafe-eval' and the local Turbopack HMR websocket
(ws://localhost:*) are added to the CSP only when
NODE_ENV === 'development', and Strict-Transport-Security /
upgrade-insecure-requests (which only make sense once real HTTPS exists)
are now skipped in dev too, since forcing an HTTPS upgrade against a plain
http://localhost dev server is a self-inflicted footgun. Production keeps
the original, stricter policy untouched. This is a useful pattern in
general: "secure" isn't one fixed configuration — a policy that's
correct in production can be actively wrong in development, and the
solution is to make the difference explicit and intentional (see the
isDev branches in next.config.js), not to loosen the policy everywhere
just to make local testing quiet.
A user reported having to click "sign in" repeatedly before it actually
worked. The cause was the same category of problem as the two bugs above —
a CSP directive that was too narrow — but harder to spot because it failed
intermittently rather than outright. Clerk's bot-protection challenge
(Cloudflare Turnstile) runs inside a Web Worker loaded from a blob: URL.
CSP has no worker-src fallback to 'self' the way some other directives
do — with no worker-src specified at all, the spec has the browser fall
back to script-src for worker loads, and this policy's script-src never
allowed blob:. So the Turnstile challenge would silently fail to
initialize on the first attempt; each retry had some chance of the browser
having already cached or resolved enough of it to squeak through, which is
exactly what "have to click it a few times" feels like from the outside —
never a clean failure, just unreliable.
The fix is a dedicated worker-src 'self' blob: directive, confirmed
against Clerk's own published CSP requirements (clerk.com/docs/guides/secure/best-practices/csp-headers)
rather than guessed at. It's a good example of why intermittent auth
flakiness is worth treating as a CSP suspect early: a complete block
shows up immediately and loudly (like the eval() bug above); a
partial one — blocking a resource that's only sometimes on the critical
path, like a bot-protection worker — produces exactly the "sometimes it
works" pattern that's tempting to blame on the network or the browser
instead.
Three separate real bugs in a row traced back to the CSP being too narrow
in some specific way (eval(), then worker-src, and CSP remains a
plausible suspect for anything auth-related that renders incompletely).
Patching one directive at a time based on guesses about what Clerk might
need next is slow and, from the outside, indistinguishable from not
actually knowing what's wrong. So next.config.js now has a
DISABLE_CSP_FOR_DEBUGGING environment variable (documented, commented
out by default, in .env.example) that skips every security header
entirely when set. Setting it locally and reloading a broken page answers
one question immediately: is the CSP even involved, yes or no? If the page
starts working, turn the flag back off and check the browser console for
the specific "Refused to ... violates the following Content Security
Policy directive" message, which names the exact directive to fix — far
faster than iterating blind. If the page is still broken with the CSP
completely off, the cause is something else entirely (a JS error, a bad
Clerk key, a stale build), and no further CSP changes will help.
This must never be set in Vercel or any real deployment — it isn't a convenience setting, it removes clickjacking/XSS/injection protections outright. It exists purely so a broken-locally problem can be triaged in one step instead of several round trips of "try this directive, redeploy, check again."
Being honest about the edges of this is as important as the list above.
- In-memory rate limiting doesn't scale across multiple server
instances. If you deploy more than one instance behind a load balancer,
each gets its own counter. Fine for a single Railway/Render service or
local use; if you scale horizontally, swap
src/lib/rateLimit.tsfor a shared store (Upstash Redis is the natural fit — the file has a comment marking exactly what to change). - TLS/HTTPS itself is your hosting provider's job, not this codebase's.
Strict-Transport-Securitytells browsers to insist on HTTPS once it exists; it doesn't create the certificate. Vercel, Railway, etc. handle this automatically — just don't turn it off. - Dependency vulnerabilities will be found after this is written, in
Next.js, Clerk's SDK, Prisma, or anything else in
package.json. That's what Dependabot andnpm auditare for — they only help if someone actually merges the updates. Set a calendar reminder, don't just leave the PRs open. - Your Clerk and database credentials are only as safe as wherever you store them. Use your hosting provider's secret manager, not a shared doc or a Slack message. Rotate the Clerk secret key and DB password if you ever suspect they leaked (e.g. an accidental commit).
- This has not been through a third-party penetration test or formal security audit. The list above is solid, current best practice — it is not a substitute for one if this is ever handling real patients' data, real payments, or anything with genuine regulatory stakes.
- Real Clerk keys in your host's env vars (never in git)
-
DATABASE_URLpointed at a real Postgres instance with a least-privilege app user -
prisma migrate deployrun against production - HTTPS confirmed working (most hosts do this automatically — verify it)
-
npm run auditclean, or accepted risk documented for anything that isn't - Dependabot PRs enabled and someone assigned to actually review them
- If you add features that touch money, health records, or anything regulated: get an actual security review before launch, not just this document