Skip to content
Knowledge Hub

Secure coding for backends: the vulnerabilities live in the code you write Intermediate

Estimated reading time: 24 min read

A PR adds the endpoint GET /reports/export?email=...&sort=...&file=.... Forty lines of code, green tests, the reviewer approves. Inside it:

  • email is concatenated straight into the SQL → data from other tenants can be read.
  • sort is stuffed into ORDER BY → yet another injection path.
  • file is path.joined with the export directory → files outside the directory can be read.
  • DB errors return the raw err.message to the client, and logs record the Authorization header 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.

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'Brien is 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

main.ts
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.ts
export 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”

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.

// WRONG
await db.query(`SELECT id, email FROM users WHERE tenant_id = ${tenantId} AND email = '${email}'`);
// RIGHT
await 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:

Terminal window
npx --prefix examples ts-node examples/src/secure-coding/sql-injection-demo.ts
concatenation -> [ '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 -> 400

The 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.

PostgreSQL does not accept parameters for identifiers (table names, column names) or keywords (ASC/DESC). The demo shows two kinds of failure:

  • FROM $1 raises a syntax error: easy to spot.
  • ORDER BY $1 raises 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.

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):

  1. Revoke the old secret at the provider immediately. Restarting the app revokes nothing.
  2. Create a new secret and deploy; this process must be automatable, otherwise step 1 gets delayed.
  3. Check the provider’s audit log for the exposure window.
  4. 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.
  5. Write a postmortem: why didn’t the scanner catch it.

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 request
logger.info({ req }, 'incoming'); // headers.authorization, cookie, body.password
// RIGHT: pino redaction at the source + chosen fields
const 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 redact cost more than specific paths; list specific paths for known fields.
  • Structured (JSON) logs also reduce log injection: input containing \n cannot 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:

Terminal window
npx --prefix examples ts-node examples/src/secure-coding/password-hash-demo.ts
node v24.17.0
sha256 x100,000 : 60.2 ms
scrypt N=2^17 (default) : Invalid scrypt params: error:030000AC:digital envelope routines::memory limit exceeded
scrypt N=2^17, r=8, p=1 : 173.4 ms
argon2id m=19MiB,t=2,p=1 : 13.7 ms
verify (timingSafeEqual) : true
scrypt 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, so maxmem must 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.

Terminal window
npx --prefix examples ts-node examples/src/secure-coding/random-token-demo.ts
Math.random token : 9eyq8035tkw (11 characters)
randomBytes(32) : 5XsZKj7gUFZJomn5Z1F613OxoHnmn9hJQYdoOosRQb0
randomUUID() : b7dd5cd6-db97-476e-9bfc-b39a576a8a08
6-digit OTP randomInt : 784789
safeEqual right : true
safeEqual wrong : false
different 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.

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.

// WRONG
catch (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 filter
catch (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.

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;
}
Terminal window
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 through
startsWith(BASE + sep) : false
  • Check after decoding: ..%2F is harmless while still encoded, dangerous once the framework has decoded it (line 5).
  • The prefix must include path.sep: /srv/app/uploads-evil still startsWith('/srv/app/uploads').
  • Symlinks inside BASE can still point outside; if users can create symlinks, compare against fs.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

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;
}
Terminal window
npx --prefix examples ts-node examples/src/secure-coding/prototype-pollution-demo.ts
payload keys : [ 'theme', '__proto__' ]
--disable-proto : false
before merge ({}).isAdmin = undefined
after safeMerge ({}).isAdmin = undefined -> Object.prototype clean
after naiveMerge ({}).isAdmin = true -> EVERY object "is admin"
newly created user, nothing assigned: user.isAdmin = true
constructor.prototype : ({}).polluted = yes
Object.create(null) : ({}).isAdmin = undefined | dict.__proto__ is an own key: [ 'theme', '__proto__' ]

Rerun with NODE_OPTIONS=--disable-proto=delete:

after naiveMerge ({}).isAdmin = undefined -> __proto__ blocked
newly created user, nothing assigned: user.isAdmin = undefined
constructor.prototype : ({}).polluted = yes

Exactly as OWASP describes: --disable-proto=delete is only defense in depth; the constructor.prototype path is still open. Order of defenses:

  1. Don’t deep-merge raw input; validate with a DTO first (whitelist has already removed unknown keys).
  2. For “dictionary” data where the user chooses keys: use Map, or Object.create(null).
  3. Hand-written merge/set-by-path functions must block __proto__, constructor, prototype.
  4. 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.

Terminal window
npx --prefix examples ts-node examples/src/secure-coding/redos-demo.ts
n | evil /^(a+)+$/ | safe /^a+$/
16 | false 1.28 ms | false 0.00 ms
20 | false 4.69 ms | false 0.01 ms
22 | false 15.59 ms | false 0.00 ms
24 | false 66.65 ms | false 0.00 ms
26 | false 264.13 ms | false 0.00 ms
match (26 'a') | evil true 0.01 ms | safe true 0.00 ms

Every 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 (@MaxLength in 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, use includes or 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.

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
  1. Every input (body, query, param, header, file, message) goes through a decorated DTO; whitelist and forbidNonWhitelisted are enabled globally; there are length limits.
  2. No template string contains SQL with variables from input; identifiers and ASC/DESC go through an allowlist map; grep for Unsafe, raw(.
  3. Updates use a dedicated DTO and never pass req.body to the ORM; queries are scoped by actor (BOLA).
  4. No secrets in code, test fixtures, URLs or logs; the diff contains no .env; a secret scanner runs in CI.
  5. Logs are structured and redact authorization, cookie, password, token.
  6. Passwords are hashed with Argon2id (or scrypt/bcrypt per the OWASP table); tokens are generated with crypto; secrets are compared with timingSafeEqual.
  7. 5xx responses contain no stack traces, SQL statements or paths; there is an errorId for log lookup.
  8. File paths from input go through resolve + the BASE + sep prefix, or better, use a fileId; no new RegExp(input), no deep-merging raw input.
  9. New dependencies have a reason; CI uses npm ci and npm audit.

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?