Secure coding for backends: the vulnerabilities live in the code you write Intermediate
Estimated reading time: 24 min read
The problem
Section titled “The problem”A PR adds the endpoint GET /reports/export?email=...&sort=...&file=.... Forty lines of code, green tests, the reviewer approves. Inside it:
emailis concatenated straight into the SQL → data from other tenants can be read.sortis stuffed intoORDER BY→ yet another injection path.fileispath.joined with the export directory → files outside the directory can be read.- DB errors return the raw
err.messageto the client, and logs record theAuthorizationheader too.
None of these bugs needs a complex architecture to fix. They are coding mistakes, and they only get caught if the reviewer knows where to look. This article is that “where to look” list, each item with a reason, before/after and a demo that actually runs.
Other articles go deep on individual areas; here they are only linked: object-level authorization (BOLA/IDOR), CORS/CSRF, SSRF, session/JWT, login rate limiting.
Quick summary: vulnerability → the right way to write it
Section titled “Quick summary: vulnerability → the right way to write it”| Vulnerability | Common wrong way | Right way (default) | OWASP Top 10:2021 (CWE) |
|---|---|---|---|
| Uncontrolled input | Trusting req.body as an object of the right type |
Schema validation at the edge, field allowlist (whitelist + forbidNonWhitelisted) |
A03 Injection (CWE-20) |
| Mass assignment | repo.update(id, req.body) |
A DTO containing only fields allowed to change | A08 Software and Data Integrity Failures (CWE-915) |
| SQL injection | Template string '${email}' |
Parameterized query $1, values sent separately |
A03 Injection (CWE-89) |
| Dynamic identifiers | ORDER BY ${sort} |
Map through an allowlist to fixed SQL | A03 Injection |
| Leaked secrets | Hard-coded, committed .env, printed to logs/URLs |
Secret manager, injected at runtime, rotatable | A07 Identification and Authentication Failures (CWE-798) |
| Data leaking through logs | Logging the whole request/headers | Structured logs + redaction at the source | A09 Security Logging and Monitoring Failures (CWE-532, CWE-117) |
| Passwords | MD5/SHA-256, or two-way encryption | Argon2id (m = 19 MiB, t = 2, p = 1) per OWASP | A02 Cryptographic Failures (CWE-916) |
| Guessable tokens | Math.random() |
crypto.randomBytes / randomUUID |
A02 Cryptographic Failures (CWE-330, CWE-338) |
| Comparing secrets | a === b |
crypto.timingSafeEqual |
No dedicated category |
| Errors exposing internals | Returning stack traces, SQL statements | Generic problem details, details go to logs | A04 Insecure Design (CWE-209) |
| Path traversal | path.join(base, input) |
path.resolve + checking the base + sep prefix |
A01 Broken Access Control (CWE-22) |
| Prototype pollution | Deep-merging JSON without filtering keys | Drop __proto__/constructor/prototype, use Map |
CWE-1321, a child of CWE-915 (A08) |
| ReDoS | Regexes with nested quantifiers | Linear regexes + input length limits | No dedicated category |
| Dependencies | npm install in CI, adding packages for convenience |
npm ci + lockfile, npm audit in CI, few dependencies |
A06 Vulnerable and Outdated Components |
The OWASP column is placed according to each category’s CWE list; a real bug often touches several categories. Mass assignment in APIs also has its own name, BOPLA (API3:2023).
OWASP has released Top 10:2025; the table above still uses the 2021 edition because the CWE mapping here was checked against it.
Trust boundaries: where data flows
Section titled “Trust boundaries: where data flows”Every rule below revolves around one question: where did this data come from, and which control has it passed? Bodies, query strings, headers, file uploads, queue messages and third-party responses are all untrusted.
Loading diagram…
The two layers have different roles:
- The edge rejects malformed input (type, length, enum). It reduces the attack surface but does not replace the safety step at the sink.
- The sink (SQL, files, logs, HTML, shell) must be safe for every valid string. The name
O'Brienis valid, but it still breaks string-concatenated SQL.
The OWASP Input Validation Cheat Sheet says it plainly: validation does not replace parameterized queries or output encoding.
1. Validate at the edge: allowlist, not denylist
Section titled “1. Validate at the edge: allowlist, not denylist”Denylists (“block quotes”, “block <script>”) are always incomplete and also block valid data. An allowlist defines what is accepted; everything else is rejected.
Wrong: trusting the body to be the right type
@Patch('me')updateMe(@Req() req: Request, @Body() body: any) { return this.users.update(req.user.id, body); // body may contain role, tenantId, isVerified...}Right: DTO + a global ValidationPipe
app.useGlobalPipes(new ValidationPipe({ whitelist: true, // strip properties without decorators forbidNonWhitelisted: true, // instead of stripping, return 400 so the client knows it sent bad data transform: true, // return a DTO instance, convert param types}));
// update-me.dto.tsexport class UpdateMeDto { @IsOptional() @IsString() @Length(1, 80) displayName?: string;
@IsOptional() @IsIn(['vi', 'en']) locale?: string;}Per the NestJS docs, whitelist strips properties that have no validation decorator; forbidNonWhitelisted turns silent stripping into an exception. Enable both by default: a request sending {"role":"admin"} gets a 400 instead of silently succeeding.
Three points for reviewers to check:
| Point | Why |
|---|---|
| Every field in the DTO has a decorator | Undecorated fields are removed by whitelist, which easily becomes a “field doesn’t save” bug, and then someone turns whitelist off |
| Limits on length, value range, array size | A 10 MB string of the right type is still a DoS |
| Separate DTOs for create, update, admin update | One shared DTO lets role slip into a regular user’s endpoint |
Semantic checks (end date after start date, orderId belongs to this user) belong in business logic. The “which fields may the user edit” part is exactly BOPLA/mass assignment: see BOLA, BFLA, BOPLA.
2. SQL injection: values go separately, identifiers go through an allowlist
Section titled “2. SQL injection: values go separately, identifiers go through an allowlist”Parameterized queries
Section titled “Parameterized queries”node-postgres sends the SQL text and the parameter array separately to the server; the server substitutes the values and the client never concatenates strings. So x' OR '1'='1 is just an odd email.
// WRONGawait db.query(`SELECT id, email FROM users WHERE tenant_id = ${tenantId} AND email = '${email}'`);
// RIGHTawait db.query('SELECT id, email FROM users WHERE tenant_id = $1 AND email = $2', [tenantId, email]);The demo runs against the repo’s PostgreSQL 17, in a temporary scratch_sec database, with 3 users across 2 tenants and the input email = x' OR '1'='1:
npx --prefix examples ts-node examples/src/secure-coding/sql-injection-demo.tsconcatenation -> [ 'an@a.vn', 'binh@a.vn', 'ceo@b.vn' ]parameterized -> []ORDER BY $1 -> [ 1, 2, 3 ] (not sorted by the column)FROM $1 -> syntax error at or near "$1"allowlist sort -> [ 2, 3, 1 ]unknown input -> undefined -> 400The first line is scarier than it looks: ceo@b.vn belongs to tenant 2. Because AND binds tighter than OR, the tenant_id = 1 condition is neutralized. The injection here is also an authorization bug.
Identifiers cannot be parameters
Section titled “Identifiers cannot be parameters”PostgreSQL does not accept parameters for identifiers (table names, column names) or keywords (ASC/DESC). The demo shows two kinds of failure:
FROM $1raises a syntax error: easy to spot.ORDER BY $1raises no error; it just sorts by a constant. The result is[1, 2, 3]instead of[2, 3, 1]; this kind of bug is often “fixed” by going back to string concatenation.
The right way per OWASP: map input to fixed SQL defined in code.
const SORT = { newest: 'created_at DESC', oldest: 'created_at ASC', email: 'email ASC',} as const;
const clause = SORT[query.sort as keyof typeof SORT];if (!clause) throw new BadRequestException('invalid sort');await db.query(`SELECT id FROM users WHERE tenant_id = $1 ORDER BY ${clause}, id`, [tenantId]);The string concatenated into SQL here is a constant in code, not input. With a DTO, @IsIn(Object.keys(SORT)) blocks it at the edge, and the map remains the guard at the sink.
ORMs are not immune: inspect the raw escape hatches
Section titled “ORMs are not immune: inspect the raw escape hatches”| Library | Safe | Dangerous |
|---|---|---|
| node-postgres | query(text, values) |
Template strings in text |
| Prisma ORM 7 (stable) | $queryRaw`... ${x}` (tagged template, becomes a prepared statement) |
$queryRawUnsafe(str), misusing Prisma.raw |
| TypeORM QueryBuilder | .where('u.email = :email', { email }) |
.where(`u.email = '${email}'`) |
| Knex | knex.raw('... WHERE email = ?', [email]); identifiers via ?? or :col: |
knex.raw(`... '${email}'`), whereRaw/orderByRaw with hand-built strings |
| Kysely | The regular query builder, or sql`... ${email}` (values always become parameters) |
sql.raw(...); also sql.ref, sql.id, sql.table, sql.lit (Kysely docs: using them with unchecked input “WILL lead to SQL injection”) |
Review rule: grep for Unsafe, raw(, Raw(, sql.raw, sql.ref, .query( and every template string containing SQL.
A common misunderstanding: identifier helpers (Knex’s ??, Kysely’s sql.ref/sql.id) only quote the column name; they don’t decide which columns the user may choose. Taking sort straight from the query string into ?? still allows sorting by sensitive columns, and Kysely states that those helpers are not safe with unchecked input.
The allowlist map above remains mandatory. The Prisma docs also note that variables in $queryRaw cannot be used for column names, table names or keywords; those still need an allowlist.
Prisma ORM 8 (a release candidate at the time of writing; the stable version is still 7) changes the raw API to a db.raw.sql tagged template (interpolated values always become parameters), and its docs still require choosing column/table names from a fixed set in code.
3. Secrets: not in code, logs or URLs
Section titled “3. Secrets: not in code, logs or URLs”| Rule | Reason |
|---|---|
Don’t hard-code, don’t commit .env |
Git keeps history; deleting in a later commit doesn’t remove it from history or existing clones |
Don’t put secrets in URLs (?api_key=) |
URLs end up in access logs, proxy logs, browser history, the Referer header |
Don’t log config or process.env at startup |
One debug line and the secret sits in the log store for months |
| Use a secret manager (Vault, AWS Secrets Manager, GCP Secret Manager…) | Access control, audit, automatic rotation |
| One secret per environment and per service | A leak in one place doesn’t take everything with it |
| Enable secret scanning in pre-commit and CI | Far cheaper than dealing with a leak afterwards |
Environment variables are convenient but not the best place to keep secrets. The OWASP Secrets Management Cheat Sheet notes that env vars are often readable by other processes and can end up in logs or dumps, so only use them when there is no better option.
A common approach: the secret manager injects at deploy time, and the app reads once at startup.
When a secret leaks (committed to a public repo, pasted into chat, sitting in logs):
- Revoke the old secret at the provider immediately. Restarting the app revokes nothing.
- Create a new secret and deploy; this process must be automatable, otherwise step 1 gets delayed.
- Check the provider’s audit log for the exposure window.
- Remove the secret from where it leaked (logs, tickets). Rewriting git history is optional and has consequences (changed commit hashes, broken links); it does not replace step 1.
- Write a postmortem: why didn’t the scanner catch it.
4. Logging: structured, with redaction
Section titled “4. Logging: structured, with redaction”Logs are a long-lived data store, read by many people, and rarely protected like a database. The OWASP Logging Cheat Sheet lists things that usually should not be logged directly: session IDs, access tokens, passwords, connection strings, encryption keys, card data, sensitive PII.
// WRONG: logging the whole requestlogger.info({ req }, 'incoming'); // headers.authorization, cookie, body.password
// RIGHT: pino redaction at the source + chosen fieldsconst logger = pino({ redact: { paths: ['req.headers.authorization', 'req.headers.cookie', '*.password', '*.token'], censor: '[REDACTED]', },});logger.info({ userId, orderId, route: 'POST /orders' }, 'order.created');- Wildcards in
redactcost more than specific paths; list specific paths for known fields. - Structured (JSON) logs also reduce log injection: input containing
\ncannot create fake log lines the way it can with text logs. - Need to identify a session for investigation? Log a hash of the session ID, not the value.
Separating logs, metrics and traces, and retention rules: see Logs, metrics, traces.
5. Passwords: slow hashing, not encryption
Section titled “5. Passwords: slow hashing, not encryption”Passwords need one-way hashing with a slow, parameterized algorithm. Two-way encryption means whoever has the key has every password. MD5/SHA-256 are fast hash functions: good for checksums, bad for passwords because offline brute force is fast too.
Minimum parameters per the OWASP Password Storage Cheat Sheet:
| Algorithm | OWASP parameters | When to use |
|---|---|---|
| Argon2id | m = 19 MiB, t = 2, p = 1 (equivalents: 46 MiB/t=1, 12 MiB/t=3, 9 MiB/t=4, 7 MiB/t=5, all p = 1) | Default |
| scrypt | N = 2^17, r = 8 (1024 bytes), p = 1 | When Argon2id is unavailable |
| bcrypt | work factor ≥ 10; input up to 72 bytes | Legacy systems |
| PBKDF2 | HMAC-SHA-256: 600,000 iterations; HMAC-SHA-512: 220,000 iterations | When FIPS-140 compliance is required |
Node.js has had crypto.argon2 since v24.7.0, and crypto.scrypt for a long time. Demo measured on Apple M5, Node v24.17.0:
npx --prefix examples ts-node examples/src/secure-coding/password-hash-demo.tsnode v24.17.0sha256 x100,000 : 60.2 msscrypt N=2^17 (default) : Invalid scrypt params: error:030000AC:digital envelope routines::memory limit exceededscrypt N=2^17, r=8, p=1 : 173.4 msargon2id m=19MiB,t=2,p=1 : 13.7 msverify (timingSafeEqual) : truescrypt key (base64) : AQlU+LqeQve9Tr3T...Reading the output:
- 100,000 SHA-256 computations take about 60 ms on one core: someone with the dump can try millions of passwords per second.
- Each argon2id hash costs about 14 ms and 19 MiB of RAM; the memory cost makes GPU brute force much more expensive.
- scrypt with N = 2^17, r = 8 needs about 128 MiB (128 × N × r bytes), exceeding the default
maxmem, somaxmemmust be raised. This error is often “fixed” by lowering N.
Store alongside the hash: the algorithm, parameters and salt (random, one per user). That way you can raise the parameters later and rehash when the user logs in successfully. Compare hashes with timingSafeEqual, not ===.
6. Randomness and constant-time comparison
Section titled “6. Randomness and constant-time comparison”Math.random() is not a cryptographically secure source of randomness (MDN). Don’t use it for password reset tokens, session IDs, OTPs, API keys or invite codes.
npx --prefix examples ts-node examples/src/secure-coding/random-token-demo.tsMath.random token : 9eyq8035tkw (11 characters)randomBytes(32) : 5XsZKj7gUFZJomn5Z1F613OxoHnmn9hJQYdoOosRQb0randomUUID() : b7dd5cd6-db97-476e-9bfc-b39a576a8a086-digit OTP randomInt : 784789safeEqual right : truesafeEqual wrong : falsedifferent length : ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH| Need | Use |
|---|---|
| Tokens, API keys, reset links | randomBytes(32).toString('base64url') |
| Hard-to-guess IDs | randomUUID() (UUID v4 from a CSPRNG) |
| Numeric OTPs | randomInt(0, 1_000_000) (don’t use % 10**6 on random bytes; it skews the distribution) |
| Comparing secrets, signatures, tokens | timingSafeEqual on two Buffers of the same length |
a === b stops at the first differing byte, so response time leaks the length of the matching prefix. timingSafeEqual throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH on different lengths; either check the length first (as with webhook signatures), or hash both to the same length as in the demo.
Tokens stored in the database (reset links, API keys) should be stored as a hash of the token, like passwords, but without slow hashing because the token already has enough entropy.
7. Errors must not tell internal stories
Section titled “7. Errors must not tell internal stories”Stack traces, failed SQL statements, file paths and framework versions help attackers locate injection points. OWASP Error Handling Cheat Sheet: return a generic response, record details on the server.
// WRONGcatch (err) { res.status(500).json({ error: err.message, stack: err.stack }); }// err.message: 'duplicate key value violates unique constraint "users_email_key"'
// RIGHT: a global exception filtercatch (exception, host) { const errorId = randomUUID(); this.logger.error({ errorId, err: exception }, 'unhandled'); res.status(500).type('application/problem+json').json({ type: 'about:blank', title: 'Internal Server Error', status: 500, instance: `/errors/${errorId}`, });}Expected business errors (409, 422) should still be clear to the client, per Problem Details (RFC 9457). Only unexpected errors are masked. For login, “wrong password” and “no such account” return the same message.
8. Path traversal and file uploads
Section titled “8. Path traversal and file uploads”path.join(base, input) normalizes .. and then returns a path outside base without any warning. The Node docs also state that path.isAbsolute() is not safe for preventing path traversal.
const BASE = path.resolve('/srv/app/uploads');
function safePath(name: string): string { const target = path.resolve(BASE, name); if (!target.startsWith(BASE + path.sep)) throw new BadRequestException('invalid path'); return target;}npx --prefix examples ts-node examples/src/secure-coding/path-traversal-demo.ts"invoice-42.pdf" join -> /srv/app/uploads/invoice-42.pdf safe -> /srv/app/uploads/invoice-42.pdf"../../../etc/passwd" join -> /etc/passwd safe -> REJECTED"/etc/passwd" join -> /srv/app/uploads/etc/passwd safe -> REJECTED"..%2F..%2Fetc%2Fpasswd" join -> /srv/app/uploads/..%2F..%2Fetc%2Fpasswd safe -> /srv/app/uploads/..%2F..%2Fetc%2Fpasswd"../../etc/passwd" join -> /srv/etc/passwd safe -> REJECTED"../uploads-evil/x.sh" join -> /srv/app/uploads-evil/x.sh safe -> REJECTED"a/../../uploads/ok.txt" join -> /srv/app/uploads/ok.txt safe -> /srv/app/uploads/ok.txt
startsWith(BASE) : true <- slips throughstartsWith(BASE + sep) : false- Check after decoding:
..%2Fis harmless while still encoded, dangerous once the framework has decoded it (line 5). - The prefix must include
path.sep:/srv/app/uploads-evilstillstartsWith('/srv/app/uploads'). - Symlinks inside
BASEcan still point outside; if users can create symlinks, compare againstfs.realpath.
Better still: don’t accept file names from the client. The client sends a fileId, and the server looks up the files table (with an object-level permission check) to get the path.
For uploads, the OWASP File Upload Cheat Sheet recommends:
| Rule | Reason |
|---|---|
| Allowlist extensions per business need | Denylists are always incomplete (.phtml, .svg containing script…) |
Don’t trust the Content-Type header |
The client can set it |
| Rename to an app-generated name (for example a UUID) | Original names contain ../, odd characters, overwrite files |
| Limit size | Protects disk space and memory |
| Store outside the webroot, ideally in separate storage | Uploaded files must never be executed or served directly |
9. Prototype pollution
Section titled “9. Prototype pollution”JSON from the client contains a __proto__ key. JSON.parse creates it as an ordinary own property, but a naive deep merge function assigning target['__proto__']... writes into Object.prototype. From then on every object in the process has the new property.
const FORBIDDEN = new Set(['__proto__', 'constructor', 'prototype']);
function safeMerge(target: Obj, source: Obj): Obj { for (const key of Object.keys(source)) { if (FORBIDDEN.has(key)) continue; const value = source[key]; if (isObj(value)) { const next = Object.hasOwn(target, key) && isObj(target[key]) ? (target[key] as Obj) : {}; target[key] = safeMerge(next, value); } else { target[key] = value; } } return target;}npx --prefix examples ts-node examples/src/secure-coding/prototype-pollution-demo.tspayload keys : [ 'theme', '__proto__' ]--disable-proto : falsebefore merge ({}).isAdmin = undefinedafter safeMerge ({}).isAdmin = undefined -> Object.prototype cleanafter naiveMerge ({}).isAdmin = true -> EVERY object "is admin"newly created user, nothing assigned: user.isAdmin = trueconstructor.prototype : ({}).polluted = yesObject.create(null) : ({}).isAdmin = undefined | dict.__proto__ is an own key: [ 'theme', '__proto__' ]Rerun with NODE_OPTIONS=--disable-proto=delete:
after naiveMerge ({}).isAdmin = undefined -> __proto__ blockednewly created user, nothing assigned: user.isAdmin = undefinedconstructor.prototype : ({}).polluted = yesExactly as OWASP describes: --disable-proto=delete is only defense in depth; the constructor.prototype path is still open. Order of defenses:
- Don’t deep-merge raw input; validate with a DTO first (
whitelisthas already removed unknown keys). - For “dictionary” data where the user chooses keys: use
Map, orObject.create(null). - Hand-written merge/set-by-path functions must block
__proto__,constructor,prototype. - Keep merge/clone/query-string libraries on patched versions; this group often has prototype pollution CVEs.
10. ReDoS: regexes that run in exponential time
Section titled “10. ReDoS: regexes that run in exponential time”V8’s regex engine uses backtracking. A pattern with nested quantifiers like ^(a+)+$ has to try a number of splits that doubles with every character when the input nearly matches and then fails at the end. The event loop is blocked and the whole process stops serving.
npx --prefix examples ts-node examples/src/secure-coding/redos-demo.tsn | evil /^(a+)+$/ | safe /^a+$/16 | false 1.28 ms | false 0.00 ms20 | false 4.69 ms | false 0.01 ms22 | false 15.59 ms | false 0.00 ms24 | false 66.65 ms | false 0.00 ms26 | false 264.13 ms | false 0.00 ms
match (26 'a') | evil true 0.01 ms | safe true 0.00 msEvery 2 extra characters multiply the time by about 4. At 26 characters it is already 264 ms; at that rate, 10 more characters multiply it by about 2^10, meaning minutes of a blocked event loop. The last line shows why tests usually don’t catch it: matching input runs instantly.
The right way to write it:
- Limit the length before matching (
@MaxLengthin the DTO), because the cost grows with length. - Avoid groups with quantifiers containing quantifiers (
(a+)+,([a-z]+)*) or overlapping alternation ((a|aa)+). - Use built-in validators (
@IsEmail,@IsUUID) instead of writing your own email regex. - Never create
new RegExp(userInput); if you must search for a user-entered string, useincludesor escape it.
11. Dependencies: every package is code you haven’t reviewed
Section titled “11. Dependencies: every package is code you haven’t reviewed”| Task | Command / approach | Reason |
|---|---|---|
| Install exactly per the lockfile in CI | npm ci |
Fails if package.json is out of sync with the lockfile, never modifies the lockfile |
| Commit the lockfile | package-lock.json in git |
Reproducible builds, reviewable dependency diffs |
| Scan for known vulnerabilities | npm audit --audit-level=high in CI |
Non-zero exit code when there are vulnerabilities at or above the set level |
| Verify registry signatures | npm audit signatures |
Detects packages altered relative to the registry |
| Fewer dependencies | Ask “can I write these 20 lines myself?” before adding one | Every package pulls in a transitive tree and install scripts |
| Consider disabling install scripts | npm ci --ignore-scripts if the project doesn’t need them |
Install scripts run arbitrary code at install time |
Regular updates by a bot (Dependabot, Renovate) with small PRs are much easier than bumping 30 packages at once when an urgent CVE lands.
When to apply / exceptions
Section titled “When to apply / exceptions”| Rule | Always apply | Reasonable exceptions |
|---|---|---|
| Parameterized queries | Every value in SQL | None. Identifiers go through an allowlist; that is not an exception |
whitelist + forbidNonWhitelisted |
Public APIs, internal APIs receiving client input | Endpoints that deliberately accept an open payload (a webhook storing the raw body): validate the part you use, store the rest as raw JSON, don’t merge it into objects |
| Argon2id | User passwords | 256-bit random tokens: SHA-256 is enough because they can’t be brute-forced |
crypto instead of Math.random |
Every security-related value | UI shuffling, retry jitter, log sampling |
| Log redaction | Production | Local environments with fake data |
| Avoid hand-written regexes | Client input | Simple regexes with no nested quantifiers, on length-limited input |
Code review checklist
Section titled “Code review checklist”- Every input (body, query, param, header, file, message) goes through a decorated DTO;
whitelistandforbidNonWhitelistedare enabled globally; there are length limits. - No template string contains SQL with variables from input; identifiers and
ASC/DESCgo through an allowlist map; grep forUnsafe,raw(. - Updates use a dedicated DTO and never pass
req.bodyto the ORM; queries are scoped by actor (BOLA). - No secrets in code, test fixtures, URLs or logs; the diff contains no
.env; a secret scanner runs in CI. - Logs are structured and redact
authorization,cookie,password,token. - Passwords are hashed with Argon2id (or scrypt/bcrypt per the OWASP table); tokens are generated with
crypto; secrets are compared withtimingSafeEqual. - 5xx responses contain no stack traces, SQL statements or paths; there is an
errorIdfor log lookup. - File paths from input go through
resolve+ theBASE + sepprefix, or better, use afileId; nonew RegExp(input), no deep-merging raw input. - New dependencies have a reason; CI uses
npm ciandnpm audit.
Common mistakes
Section titled “Common mistakes”Summary
Section titled “Summary”Self-check: (1) Why is ORDER BY $1 more dangerous than FROM $1? (2) In which case is path.resolve(BASE, input).startsWith(BASE) wrong? (3) Which prototype pollution variant does --disable-proto=delete block, and which does it miss? (4) When you accidentally commit an API key to a public repo, what is the first step?
References
Section titled “References”- OWASP Top 10:2021
- OWASP Input Validation Cheat Sheet
- OWASP SQL Injection Prevention Cheat Sheet
- OWASP Password Storage Cheat Sheet
- OWASP Secrets Management Cheat Sheet
- OWASP Logging Cheat Sheet
- OWASP Prototype Pollution Prevention Cheat Sheet
- OWASP File Upload Cheat Sheet
- node-postgres — Queries
- NestJS — Validation
- Node.js — Crypto