309 lines
23 KiB
Markdown
309 lines
23 KiB
Markdown
# 🧩 Stack-Specific Hardening
|
|
### Node API + Postgres/MySQL · React-Vite / Next / Vue / NestJS · with Auth, File Uploads & Stripe
|
|
|
|
> The other three docs are stack-agnostic. This one is **your** stack, with the exact mechanisms, library names, and the gotchas that actually bite Node + Postgres + Stripe apps. Where the generic docs said "enforce X," this says *how* in Nest/Express and where people get it wrong.
|
|
|
|
---
|
|
|
|
## Your stack as I understand it
|
|
|
|
- **Frontend:** varies — React+Vite, Next.js, or Vue (so: sometimes a **separate SPA**, sometimes **same-origin Next**).
|
|
- **Backend:** **Node**, often **NestJS** (also Express patterns covered).
|
|
- **Database:** **Postgres or MySQL** — self-managed, **not** a BaaS.
|
|
- **Features in play:** user logins/accounts · file uploads · Stripe payments.
|
|
- **No LLM/AI feature yet** (the agent rules for that are dormant — see §10).
|
|
- **Host:** TBD (universal, host-agnostic guidance in §9).
|
|
|
|
### ⚠️ The one assumption that changes everything
|
|
|
|
A Supabase/Firebase app exposes the database to the browser, so it's *forced* to put Row Level Security on every table — which conveniently also becomes a **backstop**: forget an auth check in code, and the database still refuses to hand over another user's data.
|
|
|
|
**You don't have that backstop by default.** Your Postgres/MySQL DB sits behind your Node API, so:
|
|
|
|
1. ✅ **Good news:** the "anyone can `curl` your database with the anon key" attack (which breaks ~half of vibe-coded apps) **doesn't apply to you** — *as long as your DB port isn't publicly reachable* (verify that, §1).
|
|
2. ❌ **The catch:** there is **no second layer** behind your app-layer authorization. One forgotten ownership check on one `:id` route = direct breach, full stop. Your handlers are the *only* thing standing between an attacker and the data.
|
|
|
|
**The fix, and the theme of this whole doc:** rebuild the safety net. Add **Postgres Row Level Security yourself** (§1), make every `:id` route enforce ownership (§3), and lock down mass-assignment (§3). Defense in depth, recreated on a stack that didn't give it to you.
|
|
|
|
---
|
|
|
|
## 0. Pick your variant (two axes decide several rules)
|
|
|
|
Your rules for **CORS, CSRF, and token storage** depend entirely on these two choices. Decide them per project:
|
|
|
|
**Axis A — Backend:** NestJS · Express · Next.js API routes.
|
|
**Axis B — Frontend ↔ API origin:**
|
|
- **Same-origin** (Next.js app + its API routes; or your SPA served from the *same site* as the API) → simpler: `SameSite=Lax` cookies cover most CSRF, no cross-origin CORS headaches.
|
|
- **Cross-origin** (Vite/Vue SPA on `app.example.com` calling an API on `api.example.com` or a different domain) → harder: you need an explicit CORS allowlist **and** either `SameSite=None; Secure` cookies + CSRF tokens, or header-based tokens (with their own XSS caveat). See §2 and §4.
|
|
|
|
> Rule of thumb: **co-locate frontend and API on the same site when you can.** Cross-origin SPA + cookies is the setup that produces the most subtle auth/CSRF bugs.
|
|
|
|
---
|
|
|
|
## 1. The database — you lost the net, rebuild it
|
|
|
|
### Lock the door first (the easy win you must verify)
|
|
- [ ] **The DB is NOT reachable from the public internet.** It listens on a private network / VPC, or is firewalled to only your app servers' IPs. Run an external port scan against your DB host — `5432` (Postgres) / `3306` (MySQL) must be closed to the world. This single check neutralizes the biggest BaaS-style risk.
|
|
- [ ] **TLS on the DB connection** (`sslmode=require` or stricter for Postgres; `ssl` options for MySQL), especially if app and DB aren't on the same private network.
|
|
- [ ] **Least-privilege DB user for the app:** it can `SELECT/INSERT/UPDATE/DELETE` on the tables it uses — and **cannot** `DROP`, cannot create users, is **not** the superuser/`postgres`/`root`. Use a *separate, more-privileged* credential for migrations only, not for the running app.
|
|
- [ ] **Default credentials changed**, strong DB password, stored only in your secret store (§9).
|
|
|
|
### Rebuild the safety net: Postgres Row Level Security
|
|
This is the layer Supabase apps get for free and you don't. Add it. Even if your app-layer checks are perfect, RLS means a *forgotten* check or a SQL-injection still returns nothing for the wrong user.
|
|
|
|
```sql
|
|
-- Example: documents table owned by users
|
|
ALTER TABLE documents ENABLE ROW LEVEL SECURITY;
|
|
ALTER TABLE documents FORCE ROW LEVEL SECURITY; -- applies even to the table owner
|
|
|
|
CREATE POLICY documents_owner ON documents
|
|
USING (user_id = current_setting('app.current_user_id', true)::uuid);
|
|
```
|
|
|
|
Then, in your Node code, set the authenticated user's id **per transaction** so the policy can see it:
|
|
|
|
```ts
|
|
// pg example — MUST be inside a transaction, using SET LOCAL so it
|
|
// does NOT leak to the next request on a pooled connection.
|
|
await client.query('BEGIN');
|
|
await client.query("SET LOCAL app.current_user_id = $1", [req.user.id]);
|
|
// ... your queries here are now automatically scoped to this user ...
|
|
await client.query('COMMIT');
|
|
```
|
|
|
|
> **Pooling gotcha:** with connection poolers (pgBouncer, Prisma's pool, etc.), use **`SET LOCAL` inside a transaction** — a plain `SET` persists on the connection and the *next* user reusing it inherits your identity. This is a real, exploitable bug. Transaction-scoped `SET LOCAL` is safe.
|
|
|
|
> **MySQL note:** MySQL has **no row-level security**. There is no database backstop available to you at all — which makes your app-layer authorization (§3) the *only* line of defense. Compensate with rigorous ownership checks, and consider restricting the app user to **views** that pre-filter by tenant where practical.
|
|
|
|
### Never hand-build SQL
|
|
- [ ] All queries are parameterized. With `pg`: `pool.query('SELECT ... WHERE id = $1', [id])` — never template strings.
|
|
- [ ] **ORM escape hatches are the trap.** Prisma's `$queryRaw` tagged-template form is safe, but **`$queryRawUnsafe(...)` is not** — never pass user input to it. Knex: use bindings, not `knex.raw('... ' + input)`. TypeORM: use parameter objects, never a string-concatenated `where`.
|
|
|
|
---
|
|
|
|
## 2. Authentication & sessions
|
|
|
|
### Where the token lives (this is a real decision, not a default)
|
|
- [ ] **Prefer `httpOnly`, `Secure` cookies** for session/JWT — JavaScript can't read them, so an XSS can't steal the token. **Avoid `localStorage` for auth tokens**: any XSS, anywhere on your site (including from a third-party script), can exfiltrate it instantly.
|
|
- [ ] **Same-origin (Next / co-located):** cookies with `SameSite=Lax` — clean and CSRF-resistant for most flows.
|
|
- [ ] **Cross-origin SPA:** cookies must be `SameSite=None; Secure` to be sent at all — which re-opens CSRF, so you **must** add CSRF protection (§4). The alternative (Bearer token in an `Authorization` header) sidesteps CSRF but forces you back to JS-readable storage and its XSS risk. Pick deliberately; co-locating avoids the dilemma.
|
|
|
|
### The auth mechanics
|
|
- [ ] Passwords hashed with **bcrypt or argon2** (`argon2` or `bcrypt` npm packages), cost ≥ 10. Never MD5/SHA-x.
|
|
- [ ] Access tokens expire (≤ 24h); use refresh tokens for longer sessions; **invalidate server-side on logout** (a deny-list or rotating refresh tokens — a stateless JWT you "delete" client-side is still valid until expiry).
|
|
- [ ] **No account enumeration:** login, signup, and password-reset return identical responses and timing whether or not the email exists.
|
|
- [ ] Password reset tokens are random (CSPRNG: `crypto.randomBytes`, **never** `Math.random()`), single-use, ~1h expiry.
|
|
- [ ] **Rate-limit auth endpoints** harder than the rest (§7). MFA available; required for admins.
|
|
|
|
### In NestJS
|
|
- Use **Guards** for authentication (e.g. a `JwtAuthGuard` via `@nestjs/passport` + `passport-jwt`), applied with `@UseGuards(...)` — the guard runs **before** the handler, which is exactly where auth belongs.
|
|
- The authenticated user arrives as `req.user` (populated by the guard/strategy). **That** is your source of identity — never a body/query field (§3, AD-3).
|
|
|
|
### In Express
|
|
- Auth middleware must be registered **before** the protected routes and **return early** with 401 on failure. The classic bug is checking auth *inside* the handler (easy to forget on the next route) instead of as middleware in front of it.
|
|
|
|
---
|
|
|
|
## 3. Authorization — your single highest risk (no net, remember)
|
|
|
|
Because you have no RLS backstop by default (or none at all on MySQL), this section is where you get breached if you're sloppy. Treat every one of these as load-bearing.
|
|
|
|
- [ ] **Identity from the session only.** Nest: `req.user.id` from the guard. Express: from your verified-token middleware. **Never** read `userId`/`accountId`/`orgId` from the URL, query, or body to decide whose data to return.
|
|
- [ ] **Ownership check on EVERY `:id` route — reads and writes.** After loading the resource, verify `resource.userId === req.user.id` (or a role/membership rule) **before** returning or mutating. Fail → **403**. Do this on `GET` too, not just `PUT/DELETE`.
|
|
- [ ] **Centralize it** so it can't be forgotten: a reusable ownership guard/policy (Nest: a custom `Guard` or `CASL` for richer rules; Express: a `requireOwnership(resourceLoader)` middleware) beats copy-pasting `if` checks into 40 handlers.
|
|
- [ ] **Admin routes check role server-side**, every time → 403 for non-admins. Don't rely on the frontend hiding the admin UI.
|
|
|
|
### Kill mass-assignment (the privilege-escalation classic)
|
|
An attacker adds `"role":"admin"` or `"isVerified":true` or `"userId":"<someone-else>"` to an otherwise-normal request body. If your code spreads the body into the DB model, they just escalated.
|
|
|
|
**NestJS — lock it globally:**
|
|
```ts
|
|
app.useGlobalPipes(new ValidationPipe({
|
|
whitelist: true, // silently strip any property not in the DTO
|
|
forbidNonWhitelisted: true, // or 400 on unexpected properties
|
|
transform: true,
|
|
}));
|
|
```
|
|
Define DTOs with `class-validator` decorators and **never** put `role`, `isAdmin`, `userId`, `balance`, etc. in a user-writable DTO. Set those server-side only.
|
|
|
|
**Express — explicit allowlist:** validate with **zod** (or `express-validator`) against a strict schema and build the DB object from *named* fields only — never `{ ...req.body }` straight into an `INSERT`/`UPDATE`.
|
|
|
|
- [ ] And again: **add Postgres RLS (§1)** so that even a slipped ownership check fails closed. On MySQL, since there's no RLS, audit these checks especially hard.
|
|
|
|
---
|
|
|
|
## 4. CORS / CSRF / security headers (variant-dependent)
|
|
|
|
### CORS — explicit allowlist, never reflect
|
|
**NestJS:**
|
|
```ts
|
|
app.enableCors({
|
|
origin: ['https://app.example.com'], // explicit list, NOT true, NOT '*'
|
|
credentials: true, // only with specific origins, never with '*'
|
|
});
|
|
```
|
|
**Express:**
|
|
```ts
|
|
app.use(cors({ origin: ['https://app.example.com'], credentials: true }));
|
|
```
|
|
- [ ] Never set `origin: '*'`, never reflect `req.headers.origin` back unconditionally, never pair a wildcard with `credentials: true`. List your real domains only.
|
|
|
|
### CSRF — needed *because* you use cookies
|
|
- [ ] **If auth is cookie-based** (recommended), you need CSRF protection on state-changing routes (`POST/PUT/PATCH/DELETE`):
|
|
- Same-origin + `SameSite=Lax` cookies → already blocks most CSRF; add tokens for sensitive actions (email/password change, payments).
|
|
- Cross-origin SPA + `SameSite=None` cookies → **CSRF tokens are mandatory.** Use the maintained **`csrf-csrf`** package (double-submit pattern). *Note: the old `csurf` package is deprecated — don't use it.*
|
|
- [ ] **If auth is a Bearer token in a header** (not a cookie) → CSRF isn't applicable (the browser won't auto-attach it cross-site), but you've taken on the XSS-token-theft risk from §2. Don't add cookie-CSRF on top; just make sure nothing reads the token from JS-accessible storage.
|
|
- [ ] No state changes via `GET`.
|
|
|
|
### Security headers — one global middleware
|
|
- [ ] **Use `helmet`.** Nest: `app.use(helmet())`. Express: `app.use(helmet())`. Then tune the **Content-Security-Policy** for your frontend (helmet's default CSP is strict and will block your own assets until configured). Ensure you're emitting CSP, HSTS, `X-Frame-Options: DENY`, `X-Content-Type-Options: nosniff`, `Referrer-Policy`.
|
|
- [ ] If the SPA is served by a CDN/host separate from the API, set headers on **both** the static host and the API.
|
|
|
|
---
|
|
|
|
## 5. File uploads — the disguised-file problem (you have this feature)
|
|
|
|
Uploads are a top foothold: a "JPG" that's actually HTML/script, served from your origin, runs as same-origin code. The client-supplied filename and `Content-Type` are **lies** — never trust them.
|
|
|
|
```ts
|
|
// 1) Limit at the parser (multer)
|
|
const upload = multer({
|
|
storage: multer.memoryStorage(),
|
|
limits: { fileSize: 5 * 1024 * 1024, files: 1 }, // hard server-side cap
|
|
});
|
|
|
|
// 2) Validate by MAGIC BYTES, not extension/mime
|
|
import { fileTypeFromBuffer } from 'file-type';
|
|
const ft = await fileTypeFromBuffer(req.file.buffer);
|
|
const ALLOWED = ['image/png', 'image/jpeg', 'image/webp'];
|
|
if (!ft || !ALLOWED.includes(ft.mime)) throw new BadRequestException('Invalid file');
|
|
|
|
// 3) Re-encode images to strip any embedded payload + EXIF (sharp)
|
|
const clean = await sharp(req.file.buffer).rotate().toFormat('webp').toBuffer();
|
|
|
|
// 4) Rename to a UUID server-side; store OFF your app origin
|
|
const key = `${crypto.randomUUID()}.webp`;
|
|
// -> upload `clean` to S3 / R2 / GCS and serve from there (or via signed URLs)
|
|
```
|
|
|
|
- [ ] Validate by **magic bytes** (`file-type`), reject anything not on your allowlist.
|
|
- [ ] **Re-encode images** (`sharp`) — turns a polyglot/malicious file into a clean re-rendered one and removes EXIF/location data.
|
|
- [ ] **Rename to a UUID** server-side (kills path traversal via filename and enumeration).
|
|
- [ ] **Store on a separate domain/bucket** (S3, Cloudflare R2, GCS) and serve from there or via **signed, expiring URLs** — never serve user uploads from your app's origin, and never write them inside your web root.
|
|
- [ ] Enforce size limits **server-side** (the multer `limits` above), not just a frontend `accept`/size attribute.
|
|
- [ ] Scan for malware if you accept non-image files (e.g. ClamAV) and add zip-bomb protection if you accept archives.
|
|
|
|
---
|
|
|
|
## 6. Stripe — the raw-body gotcha that silently breaks verification (you have this)
|
|
|
|
The #1 Stripe-on-Node bug: your JSON body parser runs first, mutates the body, and `constructEvent` can no longer verify the signature — so devs "fix" it by skipping verification. That makes your webhook forgeable by anyone with `curl`. The real fix is feeding the **raw** body to verification.
|
|
|
|
**Express — raw body on the webhook route only:**
|
|
```ts
|
|
// This route must NOT go through express.json() first.
|
|
app.post('/webhook/stripe',
|
|
express.raw({ type: 'application/json' }),
|
|
(req, res) => {
|
|
const sig = req.headers['stripe-signature'];
|
|
let event;
|
|
try {
|
|
event = stripe.webhooks.constructEvent(req.body, sig, process.env.STRIPE_WEBHOOK_SECRET);
|
|
} catch {
|
|
return res.status(400).send('bad signature'); // reject forgeries/replays
|
|
}
|
|
// ... idempotency + handling below ...
|
|
});
|
|
```
|
|
|
|
**NestJS — enable raw body and use it:**
|
|
```ts
|
|
const app = await NestFactory.create(AppModule, { rawBody: true });
|
|
// in the controller: stripe.webhooks.constructEvent(req.rawBody, sig, secret)
|
|
// (exclude the webhook path from any global body-parsing/validation)
|
|
```
|
|
|
|
Then, regardless of framework:
|
|
- [ ] **Verify the signature on every webhook**; invalid/missing → **400**. Never process an unverified event.
|
|
- [ ] **Idempotency:** store each Stripe `event.id`; if you've seen it, skip. Stripe *retries* — without this, one purchase can grant the perk multiple times.
|
|
- [ ] **Handle failure events**, not just success: `invoice.payment_failed`, `customer.subscription.deleted`, `...past_due`.
|
|
- [ ] **Entitlements come from verified webhook events / server state** — never from the browser's post-checkout redirect (which an attacker can hit directly).
|
|
- [ ] **Prices/amounts are set and validated server-side.** The client sends a product/price *id*, never a dollar amount.
|
|
- [ ] **Use Stripe Checkout or Payment Intents** so card data never touches your server — this keeps you out of heavy PCI scope. Don't build your own card form unless you know what SAQ-D means.
|
|
|
|
---
|
|
|
|
## 7. Rate limiting & abuse
|
|
|
|
- [ ] **Global limiter + stricter limits on sensitive routes** (login, signup, reset, payment, upload).
|
|
- **NestJS:** `@nestjs/throttler` — register `ThrottlerModule` and apply `ThrottlerGuard` globally, with tighter `@Throttle(...)` on auth/upload/payment routes.
|
|
- **Express:** `express-rate-limit`, with a stricter instance mounted on `/auth/*` and `/webhook` excluded or handled separately.
|
|
- [ ] **Multi-instance?** Back the limiter with **Redis** (e.g. `rate-limit-redis`, or throttler's Redis storage) so limits are shared across servers — an in-memory limiter is per-process and trivially bypassed when you scale out.
|
|
- [ ] **Don't trust `X-Forwarded-For`** for the client IP unless you're behind a known proxy and have set `app.set('trust proxy', ...)` / the equivalent correctly. Otherwise attackers spoof it to dodge limits.
|
|
- [ ] **Cap request body size** globally (`express.json({ limit: '100kb' })` / Nest body limits) so oversized payloads can't exhaust memory.
|
|
- [ ] **No endpoint returns "all rows"** — paginate list endpoints and cap page size.
|
|
|
|
---
|
|
|
|
## 8. Errors & information disclosure
|
|
|
|
- [ ] **Generic errors to the client; full detail to server logs only.** No stack traces, SQL errors, file paths, or library versions in responses.
|
|
- **NestJS:** a global **exception filter** that maps everything to a clean shape; let `HttpException`s through with their status, but never serialize raw error objects/DB errors to the client.
|
|
- **Express:** a final error-handling middleware `(err, req, res, next)` that logs `err` server-side and responds with a generic message + correct status.
|
|
- [ ] **`NODE_ENV=production`** in prod — and confirm your framework isn't emitting verbose/dev error pages. Don't ship **source maps** publicly.
|
|
- [ ] Validation errors can be specific about *which field* is wrong, but must not echo internals.
|
|
|
|
---
|
|
|
|
## 9. Secrets & infrastructure (host is TBD → universal moves)
|
|
|
|
You're not sure where this is hosted, so here's what's true **regardless of host**:
|
|
|
|
- [ ] **Secrets live in the host's env/secret store**, never committed. `.env` in `.gitignore` *before* the first commit; rotate anything ever committed. Different secrets per environment (dev/staging/prod) — no shared keys, no prod DB creds in dev.
|
|
- [ ] **Put Cloudflare (or equivalent) in front, whatever the host.** It's the universal edge layer you're otherwise missing: free WAF, DDoS protection, bot management, and edge rate-limiting — independent of where the origin runs. Turn on bot-fight mode and a sensible WAF ruleset. This is the highest-leverage infra move for your situation.
|
|
- [ ] **Force HTTPS/TLS** end to end, valid cert, HSTS on.
|
|
- [ ] **DB on a private network / IP allowlist** (re-stating §1 because it's the big one) — port closed to the public internet.
|
|
- [ ] **MFA on every control-plane account**: your host, GitHub, domain registrar, DNS, and Stripe dashboard. A phished GitHub or registrar account bypasses every control in these docs — this is the most common way small projects actually get taken over.
|
|
- [ ] **Audit DNS for dangling records** (subdomain-takeover risk) whenever you tear down a service.
|
|
- [ ] **CI/CD:** secrets in the platform's secret store; never printed in build logs; pin and review any third-party Actions.
|
|
|
|
---
|
|
|
|
## 10. AI / LLM agent — dormant (you didn't select it)
|
|
|
|
You don't currently have an AI feature in your product, so **§7 of the Adversarial Review and the AI/LLM section of CLAUDE.md don't apply yet.** Leave them in place. The moment you add any of these, they activate:
|
|
|
|
- a chatbot or assistant, anything that sends user input to an LLM API,
|
|
- an endpoint that calls OpenAI/Anthropic/etc. on a user's behalf,
|
|
- a RAG/"chat with your docs" feature, or any tool-using/autonomous agent.
|
|
|
|
When that day comes, the headline risks become **prompt injection**, **per-user token/cost abuse** (someone draining your LLM bill), and **tool/data scoping** — all already written up in the other two docs. Just flip that section "on."
|
|
|
|
---
|
|
|
|
## 11. If I were attacking THIS stack tomorrow — tailored first moves
|
|
|
|
The generic "grab the Supabase anon key and query the DB directly" move **doesn't work on you** (no exposed BaaS DB). So here's what I'd actually try, and the control that makes each a dead end:
|
|
|
|
1. **Port-scan your DB host** for an open `5432`/`3306` with default or weak creds. → *Dead end:* DB on a private network, port closed, least-privilege user (§1, §9).
|
|
2. **Find the one `:id` route missing an ownership check** and read/modify another user's data (no RLS net to catch it). → *Dead end:* ownership check on every `:id` route + Postgres RLS as backstop (§1, §3). **This is your most likely real hole — hunt for it first.**
|
|
3. **Add `"role":"admin"` / `"userId":"<yours>"` to a normal request body** and hope you spread it into the DB. → *Dead end:* `ValidationPipe` whitelist / strict zod schemas; privileged fields set server-side only (§3).
|
|
4. **Forge or replay a Stripe webhook** — very often the signature check is broken by the raw-body issue or skipped entirely. → *Dead end:* raw-body signature verification + idempotency (§6).
|
|
5. **Upload an HTML/script file named `.jpg`** and load it from your origin. → *Dead end:* magic-byte validation + re-encode + served from a separate bucket (§5).
|
|
6. **XSS anywhere → steal the JWT from `localStorage`.** → *Dead end:* token in `httpOnly` cookies, plus CSP (§2, §4).
|
|
7. **Abuse a permissive CORS config** to read your API from `evil.com` with the victim's cookies. → *Dead end:* explicit origin allowlist, no wildcard-with-credentials (§4).
|
|
8. **Credential-stuff the login / brute-force reset tokens.** → *Dead end:* rate limiting + CSPRNG single-use tokens + no enumeration + MFA (§2, §7).
|
|
9. **Phish your GitHub/registrar/host** to bypass the app entirely. → *Dead end:* MFA on every control-plane account (§9).
|
|
|
|
**If #2 and #4 are dead ends, you've closed the two holes most likely to actually exist on a Node + Postgres + Stripe app.** Start there.
|
|
|
|
---
|
|
|
|
## How this fits with your other files
|
|
|
|
- **CLAUDE.md** — keep it as your agent's rules. This doc is the concrete "how" behind those rules for your specific stack. You can paste the most stack-specific bits (the Postgres RLS pattern, the `ValidationPipe` config, the Stripe raw-body rule) directly into CLAUDE.md so your agent applies *your* mechanisms, not generic ones.
|
|
- **SECURITY-CHECKLIST.md** — still your audit pass. On your stack, weight these heaviest: #4 (IDOR), #13 (Stripe webhooks), #14 (uploads), #2 (auth), plus the DB-port and RLS items here.
|
|
- **ADVERSARIAL-ARCHITECTURE-REVIEW.md** — the reasoning. Note that *your* version of "the data layer denies by default" (AD-2) means **adding Postgres RLS yourself**, since you don't get it for free.
|
|
|
|
> Same bottom line as always: this makes you a hard, contained, monitored target — not "unhackable." Once you have real users and revenue, a human pentester is the next step, and on a no-RLS-by-default stack, getting a second set of eyes on your authorization logic specifically is the highest-value review you can buy.
|