A prototype still handles real accounts and real data. These rules avoid the
mistakes that are cheap to make and expensive to discover. The machine-checkable
subset is enforced by npm run security-lint (part of npm run check); the
judgment calls below are enforced by the /prototype and /ship skills.
Format is rule / why / how, like a standard.
Sessions, cookies, CSRF, and password hashing are Wasp's. Don't reimplement
them, don't store tokens in localStorage yourself, don't add your own session
logic. If you think you need to, you're probably solving it at the wrong layer.
- Rule: every query/action checks
context.useras its first statement (or is explicitly marked// public-operation:). - Why: Wasp exposes a callable HTTP endpoint for every declared operation.
authRequired: trueon a route only stops the page from rendering — it does nothing for the operation's endpoint. The auth check in the operation is the real boundary. - How:
if (!context.user) throw new HttpError(401). For admin-only actions, also checkcontext.user.isAdminserver-side — hiding a button in the UI is not authorization. - Where it's enforced: operations must live in a file named
operations.ts,queries.ts, oractions.ts— security-lint only checks those. An operation hidden in a differently-named file gets no auth/input enforcement. - What the linter actually proves: that a
context.usercheck exists somewhere in the operation and its input is parsed — not that the check is the first statement, nor that it's the right check. Placement (auth before any work) and correctness are reviewed, not linted; the "first statement" rule is a convention you hold, not something a regex can see.
Four comment markers tell security-lint an omission is intentional:
// public-operation: (an operation with no auth check), // no-input: (an
operation that takes no args), // external-api: (a deliberate client-side call
to a keyless public API), and // json-ld: (the one sanctioned
dangerouslySetInnerHTML — a structured-data script, see seo.md).
Each must sit on the line directly above the code it excuses (the linter
looks back a few lines only) — a marker parked at the top of the file doesn't
count. Use them to state intent, not to silence a real finding. (seo-lint adds a
fifth, // no-h1:, documented in seo.md.)
- Rule: never accept an entity id from the client as proof of ownership.
Scope by
context.user.id. - Why:
updateThing({ id })whereidcomes from the client lets any logged-in user edit anyone's row by changing the number. This is the single most common prototype vulnerability. - How:
where: { id, userId: context.user.id }(or fetch-then-check owner).src/user/operations.tsdemonstrates the pattern — copy it.
- Rule: zod-parse an operation's args before using them (or mark
// no-input:for an arg-less op). - Why: the client payload is attacker-controlled. An unvalidated field flows straight into Prisma.
- How: a
z.object({...})schema andschema.parse(rawArgs)immediately after the auth guard, before any input is used. (Auth check is the first statement so an unauthenticated caller does no work; the parse comes right after.) security-lint checks for this.
- Rule: no secret literals in source; no
process.envin client code; aREACT_APP_*var must never hold a secret;.env.serverstays git-ignored. - Why: anything in a
.tsxbundle or aREACT_APP_*var ships to the browser. A committed key is a leaked key. - How: secrets live in
.env.server(dev) and the host's env (prod). security-lint flags secret-shaped strings, clientprocess.env, and secret-named public vars.
- Rule: no
dangerouslySetInnerHTML. - Why: it's an XSS sink the moment any of the content is user-influenced.
- How: render user/markdown content with
<ReactMarkdown>.
- Rule: no
fetch("https://…")to an external origin from client code. - Why: a browser call exposes any API key it carries, has no server-side validation of the response, and is subject to CORS and the third party's rate limits per user. Keys and trust boundaries belong on the server.
- How: call the third party from an operation (or a Wasp
api) where the key lives in.env.server, then return only what the client needs. For a genuinely public, keyless API that must run in the browser, mark the call// external-api:on the line directly above it. security-lint enforces this.
- Throw
HttpError(4xx, "user-safe message"); never echo a stack trace, SQL error, or internal detail to the client. - Don't store PII you don't need (IP, user-agent, precise location). Don't log secrets or PII. Less stored data is less to leak.
-
SKIP_EMAIL_VERIFICATION_IN_DEVis absent from the production env. -
ADMIN_EMAILSis set to the real admin address(es). - All secrets are set as Railway/host env vars, never committed.
-
npm run checkis green. -
npm audit --omit=devis clean or every finding is triaged. (Wasp pulls in some deprecated auth deps; assess, don't panic.) -
head<script>s are minimal andasync(notdefer). - CSP
upgrade-insecure-requestsis present inmain.wasp.tshead. It must usehttpEquiv(camelCase — head strings are JSX;http-equivis silently dropped); seo-lint now checks this. For a real CSP, prefer a host response header over the meta tag. - Response headers reviewed on the host:
X-Content-Type-Options: nosniff, a sensibleReferrer-Policy. - Rate limiting: fine to skip at validation scale. When traffic justifies
it, add it to auth/public endpoints via a Wasp
apiwith amiddlewareConfigFn+express-rate-limit(recipe below).
- Rate limiting — declare the sensitive route as a Wasp
api()with amiddlewareConfigFnthat unshiftsexpress-rate-limit. Do this per-endpoint, not globally, so it doesn't throttle the SPA. - Webhook signature verification — see
payments-later.md. Never trust a webhook body without verifying its signature against the raw request bytes. - File uploads — validate type and size against an allowlist server-side and use presigned URLs; never proxy raw uploads through the app.