23 KiB
🧩 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:
- ✅ Good news: the "anyone can
curlyour 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). - ❌ The catch: there is no second layer behind your app-layer authorization. One forgotten ownership check on one
:idroute = 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=Laxcookies cover most CSRF, no cross-origin CORS headaches. - Cross-origin (Vite/Vue SPA on
app.example.comcalling an API onapi.example.comor a different domain) → harder: you need an explicit CORS allowlist and eitherSameSite=None; Securecookies + 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=requireor stricter for Postgres;ssloptions 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/DELETEon the tables it uses — and cannotDROP, 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.
-- 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:
// 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 LOCALinside a transaction — a plainSETpersists on the connection and the next user reusing it inherits your identity. This is a real, exploitable bug. Transaction-scopedSET LOCALis 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
$queryRawtagged-template form is safe, but$queryRawUnsafe(...)is not — never pass user input to it. Knex: use bindings, notknex.raw('... ' + input). TypeORM: use parameter objects, never a string-concatenatedwhere.
2. Authentication & sessions
Where the token lives (this is a real decision, not a default)
- Prefer
httpOnly,Securecookies for session/JWT — JavaScript can't read them, so an XSS can't steal the token. AvoidlocalStoragefor 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; Secureto be sent at all — which re-opens CSRF, so you must add CSRF protection (§4). The alternative (Bearer token in anAuthorizationheader) 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 (
argon2orbcryptnpm 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, neverMath.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
JwtAuthGuardvia@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.idfrom the guard. Express: from your verified-token middleware. Never readuserId/accountId/orgIdfrom the URL, query, or body to decide whose data to return. - Ownership check on EVERY
:idroute — reads and writes. After loading the resource, verifyresource.userId === req.user.id(or a role/membership rule) before returning or mutating. Fail → 403. Do this onGETtoo, not justPUT/DELETE. - Centralize it so it can't be forgotten: a reusable ownership guard/policy (Nest: a custom
GuardorCASLfor richer rules; Express: arequireOwnership(resourceLoader)middleware) beats copy-pastingifchecks 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:
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:
app.enableCors({
origin: ['https://app.example.com'], // explicit list, NOT true, NOT '*'
credentials: true, // only with specific origins, never with '*'
});
Express:
app.use(cors({ origin: ['https://app.example.com'], credentials: true }));
- Never set
origin: '*', never reflectreq.headers.originback unconditionally, never pair a wildcard withcredentials: 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=Laxcookies → already blocks most CSRF; add tokens for sensitive actions (email/password change, payments). - Cross-origin SPA +
SameSite=Nonecookies → CSRF tokens are mandatory. Use the maintainedcsrf-csrfpackage (double-submit pattern). Note: the oldcsurfpackage is deprecated — don't use it.
- Same-origin +
- 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.
// 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
limitsabove), not just a frontendaccept/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:
// 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:
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— registerThrottlerModuleand applyThrottlerGuardglobally, with tighter@Throttle(...)on auth/upload/payment routes. - Express:
express-rate-limit, with a stricter instance mounted on/auth/*and/webhookexcluded or handled separately.
- NestJS:
- 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-Forfor the client IP unless you're behind a known proxy and have setapp.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
HttpExceptions 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 logserrserver-side and responds with a generic message + correct status.
- NestJS: a global exception filter that maps everything to a clean shape; let
NODE_ENV=productionin 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.
.envin.gitignorebefore 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:
- Port-scan your DB host for an open
5432/3306with default or weak creds. → Dead end: DB on a private network, port closed, least-privilege user (§1, §9). - Find the one
:idroute missing an ownership check and read/modify another user's data (no RLS net to catch it). → Dead end: ownership check on every:idroute + Postgres RLS as backstop (§1, §3). This is your most likely real hole — hunt for it first. - Add
"role":"admin"/"userId":"<yours>"to a normal request body and hope you spread it into the DB. → Dead end:ValidationPipewhitelist / strict zod schemas; privileged fields set server-side only (§3). - 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).
- Upload an HTML/script file named
.jpgand load it from your origin. → Dead end: magic-byte validation + re-encode + served from a separate bucket (§5). - XSS anywhere → steal the JWT from
localStorage. → Dead end: token inhttpOnlycookies, plus CSP (§2, §4). - Abuse a permissive CORS config to read your API from
evil.comwith the victim's cookies. → Dead end: explicit origin allowlist, no wildcard-with-credentials (§4). - Credential-stuff the login / brute-force reset tokens. → Dead end: rate limiting + CSPRNG single-use tokens + no enumeration + MFA (§2, §7).
- 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
ValidationPipeconfig, 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.