SENTRISRules

The mistake not made needs no fix

AI coding tools are optimised for “does it compile”, not “is it safe”. That is the whole problem, and scanning is only where it becomes visible. A scanner tells you about one instance of a mistake; a rule in the agent's context stops the next twenty. Below is the rules file we generate: free, complete, no email.

How to use it

Copy it into CLAUDE.md, .cursorrules or .github/copilot-instructions.md at the root of your repository. Same content, which file it goes in is your agent's business, not ours. Commit it, and every future generation reads it before it writes.

the whole file

# Security rules

Generated by Sentris (sentris.dev). Keep this file in the repository and in your coding agent's context (`CLAUDE.md`, `.cursorrules`, `.github/copilot-instructions.md`; any of them, the content is the same).

These are constraints on code you are about to write, not a report. Follow them when generating or editing code in this repository, and say so when you deliberately do not.

## Enable Row Level Security in the same migration that creates the table

Every table in the `public` schema ships with `alter table <t> enable row level security;` and at least one policy, in the same migration that creates it. Never in a follow-up migration, never "in the dashboard later".

Reason: the anon key is in the browser bundle by design. A table without RLS is readable by anyone who opens devtools, and PostgREST will serve it happily.

A policy of `using (true)` is not RLS: it is RLS switched off with extra steps. Scope every policy to `auth.uid()` unless the rows are genuinely public, and write the `with check` clause too: `using` guards reads, `with check` guards writes, and a policy with only the first lets anyone insert.

## Server-side keys never enter client-reachable code

A secret is read from `process.env` inside a server-only file (a route handler, a server action, a server component) and nowhere else. Never inline a key as a literal, never prefix one `NEXT_PUBLIC_`, never import a module that holds one from a `"use client"` file.

Treat these as server-only, always: Supabase `service_role` / `sb_secret_`, Stripe `sk_*` and `rk_*`, `sk-ant-*`, OpenAI `sk-*`, Google `AIza*` and `GOCSPX-*`, GitHub `ghp_*`/`github_pat_*`, AWS `AKIA*`.

Reason: a bundler inlines whatever a client module can reach, and everything it inlines is downloadable by every visitor. A key that shipped once is public forever. Removing the line does not un-publish it, only rotating the key does.

Publishable keys (the Supabase anon key, a Stripe `pk_*`) belong in the client and are not secrets. Do not "fix" those by moving them server-side; fix the RLS that was supposed to be protecting the data behind them.

## Every route handler authenticates before it touches data

A route handler that reads a record id from the request (path param, query string, body) resolves the caller first and refuses if there is none:

const { data: { user } } = await supabase.auth.getUser();
if (!user) return NextResponse.json({ error: "Unauthorized" }, { status: 401 });

Reason: a route with no auth check is not protected by the UI not linking to it. It is a public API, and the id in the URL is the only thing standing between a visitor and every row in the table.

A handler that holds a `service_role` key bypasses RLS entirely, so for those this check is the only check there is.

## Storage buckets are private unless the contents are meant for strangers

Create buckets with `public: false` and serve files through signed URLs (`createSignedUrl`), with an access policy on `storage.objects`. Applies to `supabase/config.toml`, a SQL insert into `storage.buckets`, and `createBucket()` alike.

Reason: a public bucket is enumerable. "Nobody knows the filename" stops being true the first time a filename is predictable, and uploads named after a user id or a timestamp always are.

## Send the security response headers your framework leaves off

Set these on every HTML response, in Next.js under `headers()` in `next.config.js`, or in `vercel.json` `headers`:

· `X-Frame-Options: DENY` (or a CSP with `frame-ancestors 'none'`), which stops your app being framed for a clickjacking attack.
· `Strict-Transport-Security: max-age=31536000; includeSubDomains`, which stops a browser being downgraded to http.
· `X-Content-Type-Options: nosniff`, which stops the browser executing an upload as script by second-guessing its `Content-Type`.

Reason: a vanilla Next.js app on Vercel ships none of these by default, so "we didn't turn them off" is not the same as "they are on". Each is one line and costs nothing.

## A `security definer` function derives the caller, never trusts an argument

Every function in the `public` schema is a public endpoint: `POST /rest/v1/rpc/<fn>`. `security definer` makes it run as its owner, so Row Level Security on the tables it touches does not apply to whoever called it.

Take the identity from the session, not from the arguments, and pin the path:

create function public.my_orders()
returns setof orders
language sql
security definer
set search_path = ''
as $$
  select * from public.orders where user_id = (select auth.uid());
$$;

revoke execute on function public.my_orders from anon;

Reason: `security definer` is the one construct that steps over RLS, so a function taking `p_user_id` and returning that user's rows hands every row in the table to anyone with the anon key, with every policy still in place and still correct. Reach for `security invoker` (the default) unless the function genuinely needs to do something the caller may not, and then check the caller yourself.

## Name the columns a request may write; never hand it the parsed body

await supabase.from("profiles").update(parsed.data).eq("id", user.id);

Reason: `update(body)` sets every column the caller put in the JSON, not the ones the form shows: `role`, `is_admin`, `credits`, `user_id`. Row Level Security does not draw that line: a policy saying "you may update your own row" says nothing about which fields of it you may touch. `{ ...body }` is the same hole spread over two lines, and the columns to worry about are exactly the ones no form ever sends.

## Nothing that has to be rotated ever enters git

`.env` is in `.gitignore` before the first commit, and the repository ships a `.env.example` with the KEYS and no values. Private keys (`*.pem`, `*.key`, `id_rsa`, `*.p12`) live in the platform's secret store and are read at runtime, never committed "temporarily".

Reason: git keeps everything. A secret pushed once is in every clone, every fork and every reflog, and deleting the file in a later commit changes none of that. The only fix after the fact is rotation, which is why the rule is about never starting.

When it has already happened: rotate first, `git rm --cached` second, and treat history rewriting as optional cleanup rather than as the fix.

## Do not store what you would not want to read out loud

Passwords are not stored at all: store a verifier from a real KDF, or let Supabase Auth own them. API keys, OAuth tokens and anything regulated (card numbers, IBANs, national ids, health data) are encrypted with a key that does not live in the same database, or are not kept.

A column named `password`, `api_key`, `refresh_token` or `card_number` with type `text` is the shape to avoid. `*_hash`, `encrypted_*` and `bytea` are the shapes that say the value was protected before it was written.

Reason: Row Level Security decides who may query a row. It says nothing to a stolen backup, a support engineer with dashboard access, or a log line, and those are the paths this data actually leaks through.

## Session cookies are HttpOnly, Secure and SameSite: all three, every time

Any cookie carrying a session is written with `{ httpOnly: true, secure: true, sameSite: "lax", path: "/" }`. With Supabase, let `@supabase/ssr` write them rather than setting the session by hand: it sets these and refreshes correctly.

Never write a session from `document.cookie`: a cookie set in the browser cannot be `HttpOnly`, so that one line opts out of the most important of the three.

Reason: without `HttpOnly` every script on the page can read the session, which turns any XSS into account takeover. Without `Secure` it travels over plain http. Both default to off.

## Hash passwords with a KDF, or do not hold them

Use `supabase.auth` and let it own the credential. If you must own it yourself, use argon2, bcrypt or scrypt, and verify with the library's own `verify`/`compare`, never `===`, never a query that filters on the password column.

`createHash("sha256")` is not a password hash. Neither is MD5, SHA-1, or any of them with a salt bolted on.

Reason: a general-purpose digest is designed to be fast, and fast is the one property a password hash must not have: a leaked table of SHA-256 hashes is a leaked table of passwords by the following morning. A KDF is deliberately slow, salted per row, and compared in constant time.

## Count failed logins, per IP and per account

Every authentication entry point (sign-in, sign-up, password reset, OTP, token exchange) passes through a limiter backed by the database or Redis, keyed on BOTH the client IP and the account being targeted, counting failures and resetting on success.

Reason: password guessing and credential stuffing need no vulnerability, only an endpoint that answers as often as it is asked. Per-IP alone loses to a botnet; per-account alone lets one IP walk the whole user list. And an in-memory counter is not a limit on a serverless platform: every cold start is a fresh allowance.

Answer identically, and in the same amount of time, for "no such user" and "wrong password". A limiter in front of an endpoint that leaks which emails exist only slows the enumeration down.

## Public forms cost something to submit

Any endpoint that a stranger can call and that sends mail, writes to a shared table, or spends money carries a rate limit, and a honeypot field or a challenge (Turnstile, hCaptcha) on top of it. Verify the challenge token SERVER-side: a token checked only in the browser is not a check.

Reason: a form with no cost attached is a form a script submits ten thousand times before you wake up. The damage is rarely a breach: it is a suspended mail provider, a spent sending reputation, and a table nobody trusts any more.

## Bind values; never build a query by concatenation

SQL is written with bound parameters (`$1`, a tagged `sql`` ` template, `prisma.$queryRaw`, or the Supabase query builder). `$queryRawUnsafe`, `knex.raw(`…${x}`)` and `db.query("… " + x)` are not written at all.

The same rule covers PostgREST's filter DSL: `.or(`name.eq.${q}`)` is string building too, and a comma in `q` rewrites the filter. Use `.eq()`/`.ilike()` and pass the value as a value.

Reason: escaping is a running battle you lose once; binding ends it, because the statement is parsed before the value is ever seen and no sequence of characters can change the shape of something already parsed.

A parameter can only be a VALUE. When a table name, column name or sort direction has to vary, map the input through an object you wrote and use the mapped result.

## The caller supplies values, never identifiers

`.from()` takes a literal. `.select()` lists the columns you meant to publish. A sort key from the query string is looked up in a constant map before it reaches `.order()`, and anything not in the map is a 400.

Parse request input with `zod` at the top of the handler and use the parsed value below it: `z.enum([...])` for anything that names a column.

Reason: no database API can bind an identifier as a parameter, so the only check on a caller-chosen column or table is one you write. In PostgREST a caller-chosen `select` also reaches related tables (`select=*,users(*)`), which turns a sort option into a data export.

## Render user content as text

`{value}` in JSX, and nothing else. `dangerouslySetInnerHTML`, `innerHTML`, `insertAdjacentHTML` and `document.write` are reserved for markup you generated from your own content, and when the content is rich text from a user, it goes through `DOMPurify.sanitize` with a short `ALLOWED_TAGS` list, server-side.

Reason: React escapes interpolated values, which is why an app can be written without thinking about XSS at all. Those four APIs are the ways to opt out of that, and every one of them parses the string as markup. Stripping `<script>` yourself is not a defence: `<img onerror=…>` contains no script tag.

## Uploads are limited by the bucket, not only by the form

Every storage bucket sets `file_size_limit` and `allowed_mime_types`. The route handler checks `file.size` and `file.type` as well, and generates the storage path itself (`${user.id}/${crypto.randomUUID()}.${ext}`) rather than trusting the uploaded filename.

Reason: `supabase.storage.from(…).upload()` is callable straight from a browser with the anon key, so a check that lives only in your route is a check an attacker does not use. Bucket limits are enforced by the storage service on every path in.

Keep `image/svg+xml` off the allow list unless you need it: an SVG is a document that can carry script, and a public bucket on your own origin will happily serve it.

## Select the columns you meant to publish

Route handlers list columns: `.select("id, display_name, avatar_url")`. `select("*")` is for internal queries whose result never leaves the server.

Where the shape matters, put it in the database: a view with only the public columns, or `revoke select (email, role, stripe_customer_id) on public.profiles from anon, authenticated`.

Reason: the response body is published whether or not the UI renders it, and the same `profiles` row usually holds the display name the page shows next to the email, the role and the billing id it does not. Column grants are enforced by Postgres, so they survive the next `select("*")` somebody writes.

## Update dependencies on a schedule, not on an incident

Turn on Dependabot or Renovate, run `npm audit` in CI, and commit the lockfile. Pin transitive versions with `overrides` when a parent package is slow to update.

Reason: package.json is the only file in the repository that gets less safe while you do nothing to it. The advisory is public, the version is in your lockfile, and scanning for both is somebody's automated Tuesday.

Framework advisories are the ones that matter most here: a Next.js middleware bypass (CVE-2025-29927) turns every authorization check you wrote in `middleware.ts` off for anyone who knows the header.

## HTTPS everywhere, then HSTS

Serve only over https, redirect http permanently at the edge, and send `Strict-Transport-Security: max-age=31536000; includeSubDomains`. Reference your own assets with relative paths, and third-party ones over https, never `http://` in a `src`, `href` or `action`.

Reason: on a plain-http connection everything is readable AND rewritable in transit: an attacker on the same network injects script into the page rather than merely reading it. HSTS closes the window afterwards but is ignored when it arrives over http, so the order matters: https first, redirect second, HSTS last.

## Never combine a wildcard origin with credentials

Pin CORS to an allowlist you control, and never reflect the incoming `Origin` header back unchecked:

const ALLOWED = new Set(["https://app.example.com"]);
const origin = req.headers.get("origin") ?? "";
const headers = ALLOWED.has(origin)
  ? { "Access-Control-Allow-Origin": origin, "Access-Control-Allow-Credentials": "true", Vary: "Origin" }
  : {};

Reason: `*` plus credentials lets any site on the internet read authenticated responses on behalf of your logged-in users. Reflecting the origin is the same hole, written so it passes review.

## Scope the query to the caller, not just to the id from the request

Knowing who is calling is authentication. Limiting what they get back is authorization, and it is a separate line of code:

const { data } = await supabase
  .from("items")
  .select("*")
  .eq("id", id)
  .eq("user_id", user.id)   // ownership, not only the id from the URL
  .single();

Reason: a route that checks the session and then queries by id alone hands user A user B's record for the cost of changing one number. Keep RLS on underneath, so one forgotten `.eq` is not the whole breach.

## Verify JWT signatures: decoding is not verifying

Never read claims out of a token with `jwtDecode`, `atob` on the payload segment, or `JSON.parse` of a split. Verify against the secret and pin the algorithm:

const { payload } = await jwtVerify(token, secret, { algorithms: ["HS256"] });

With Supabase prefer `supabase.auth.getUser()` or `getClaims()`, which verify for you. `getSession()` does not, in server code.

Reason: decoding reads what the token says about itself. Anyone can write a token that says it is an admin; only the signature says who issued it.

## Rate-limit anything that spends money, sends mail, or calls a model

Gate expensive endpoints on a counter that lives in the database, not in the process:

import { allow, clientIp, tooManyRequests } from "@/lib/rate-limit";

if (!(await allow("expensive-route", clientIp(req), { max: 5, windowSeconds: 900 }))) {
  return tooManyRequests(900, "Too many requests. Try again shortly.");
}

Reason: a serverless fleet is many processes, so an in-memory limit is no limit. An endpoint with no limit that costs you money per call is a billing incident waiting for a bored visitor with a for-loop.

## NEXT_PUBLIC_ means 'published': treat the prefix as a decision, not a fix

Reach for `NEXT_PUBLIC_` only for values you would be content to print on the homepage. When a client component needs something server-side, put the work behind a route handler and call that instead of shipping the key.

Reason: the prefix compiles the value into the bundle every visitor downloads. Adding it to silence a build error publishes the secret. If it has already shipped, renaming the variable does not un-publish it. Rotate the key.

## Validate at the trust boundary, parameterise at the sink

Anything arriving from a request (body, query, header, webhook, uploaded file, or model output) is parsed and narrowed before use (`zod` is already how this codebase does it). At the far end, never build the dangerous string by concatenation:

· SQL: bound parameters or the query builder, never template literals.
· Shell: pass an argv array, never a composed command line.
· Filesystem: resolve the path and assert it is still inside the base directory.
· Outbound HTTP: allowlist the host, or you have written an SSRF.
· HTML: render as text; `dangerouslySetInnerHTML` needs a sanitizer and a reason.

Reason: injection is one bug with many names, and every one of them is "data was evaluated somewhere that expected code".

Why these ten

They are not a survey of everything that can go wrong. They are the rules behind the checks Sentris actually runs, which are in turn the mistakes that keep arriving in generated Next.js and Supabase codebases: a table shipped without Row Level Security, a service_role key in a client bundle, a route that authenticates the caller and then queries by id alone, a storage bucket left public.

Each one is phrased as a constraint on code about to be written, with the API to reach for, not as a description of a vulnerability class. A model reading “be careful about authorization” does nothing differently. A model reading .eq("id", id).eq("user_id", user.id) does.

What this file cannot do

Rules in context are a strong prior, not a guarantee. A model still forgets them under a long conversation, and code written before you committed the file is untouched by it. This is the half that keeps the number of findings from growing: it is not a reason to stop looking, and anyone selling it as one is selling you a feeling.

The other half: scan your app and find out what is already there. The paid report narrows this file to what your repository actually contains, and over MCP your coding agent fetches it itself: along with the fix prompt, and a diff_scans call so it can check whether its own fix landed.

Is this free forever?

Yes. It costs us nothing to give away and it is the most honest advertisement we have: if you read these rules, apply them by hand and never buy anything, the internet is measurably better off and we still had the better argument. Our false-positive rate is published too.