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.

## 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 ten 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.