Common API mistakes: injection, authorization, secrets, retries Intermediate
Estimated reading time: 13 min read
This page collects security mistakes that live right in handler code: fixing them needs no complex architecture, just knowing where to look during review.
Each pair is short. Deeper explanations, demos and OWASP tables are in Secure coding for backends and the other articles in the Security group.
Which checkpoints a request passes through
Section titled “Which checkpoints a request passes through”Each mistake on this page is a skipped or broken checkpoint. The numbers in the diagram match the heading groups below.
Loading diagram…
Input and injection
Section titled “Input and injection”Concatenating strings into SQL through an ORM's raw escape hatch
Common
// Knexawait knex.raw(`SELECT name FROM users WHERE tenant_id = 1 AND name = '${name}'`);// Kyselyawait sql`SELECT name FROM users WHERE tenant_id = 1 AND name = '${sql.raw(name)}'`.execute(db);// TypeORM QueryBuilderawait repo.createQueryBuilder('u').where(`u.name = '${name}'`).getMany();Prefer
await knex.raw('SELECT name FROM users WHERE tenant_id = ? AND name = ?', [tenantId, name]);await sql`SELECT name FROM users WHERE tenant_id = ${tenantId} AND name = ${name}`.execute(db);await repo.createQueryBuilder('u').where('u.name = :name', { name }).getMany();Why
- Tested with
name = x' OR '1'='1: the bad Knex and Kysely versions return all 3 users, including tenant 2’s user. The good versions return nothing. ${}in Kysely’ssqltagged template always becomes a parameter;sql.rawdoes not. The TypeORM docs call concatenating values intowhere“not safe”.- Column names and
ASC/DESCcan’t be parameterized: use an allowlist map. See SQL injection.
Mass assignment: dumping the whole body into an update
Common
@Patch('me')update(@CurrentUser() user: User, @Body() body: any) { return this.users.update(user.id, { ...body }); // body may contain role, isVerified}Prefer
app.useGlobalPipes(new ValidationPipe({ whitelist: true, forbidNonWhitelisted: true }));
export class UpdateMeDto { @IsOptional() @IsString() @Length(1, 80) displayName?: string;}
@Patch('me')update(@CurrentUser() user: User, @Body() dto: UpdateMeDto) { return this.users.update(user.id, { displayName: dto.displayName });}Why
- Body
{"displayName":"An","role":"admin"}: with onlywhitelist,roleis silently dropped; addingforbidNonWhitelistedreturns 400property role should not exist. Tested with@nestjs/common’sValidationPipe. - With
@Body() body: anyor a DTO without decorators, the pipe filters nothing. - OWASP Mass Assignment: use DTOs containing only the fields the caller may change. See Validate at the edge.
Trusting price and userId sent by the client
Common
// body: { userId, productId, qty, price }@Post('orders')create(@Body() dto: CreateOrderDto) { return this.orders.create({ userId: dto.userId, productId: dto.productId, qty: dto.qty, total: dto.price * dto.qty, });}Prefer
// body: { productId, qty }@Post('orders')async create(@CurrentUser() user: User, @Body() dto: CreateOrderDto) { const product = await this.products.findActiveOrFail(dto.productId); return this.orders.create({ userId: user.id, // from the verified token productId: product.id, qty: dto.qty, total: product.price * dto.qty, // price from the database });}Why
- The client belongs to the user: editing a request with DevTools or
curltakes seconds.price: 1buys an iPhone for 1 dong; a differentuserIdplaces orders under someone else’s name. - Rule: anything the server already knows (identity, price, permissions, state) is not taken from the body. The DTO shouldn’t have those fields in the first place.
- Validate
qtywith bounds (@IsInt() @Min(1) @Max(100)); negative numbers are another way to get a “discount”.
Authentication and authorization
Section titled “Authentication and authorization”Fetching an object by id without checking ownership (BOLA)
Common
@Get('orders/:id')get(@Param('id', ParseIntPipe) id: number) { return this.orderRepo.findOneByOrFail({ id }); // any logged-in user can read every order}Prefer
@Get('orders/:id')async get(@CurrentUser() user: User, @Param('id', ParseIntPipe) id: number) { const order = await this.orderRepo.findOneBy({ id, userId: user.id }); if (!order) throw new NotFoundException(); // 404, not 403: don't confirm the id exists return order;}Why
- Changing
/orders/1001to/orders/1002reads someone else’s order. This is the number 1 issue in the OWASP API Security Top 10. - Having an authentication guard does not mean you have authorization: the guard knows who you are, not whose object this is.
- Put the ownership condition into the query itself instead of reading then comparing, so you don’t forget it on update/delete endpoints. See BOLA/IDOR.
jwt.decode instead of verify
Common
const token = req.headers.authorization?.slice('Bearer '.length);const payload = this.jwt.decode(token); // @nestjs/jwt, or jsonwebtoken.decodereq.user = { id: payload.sub, role: payload.role };Prefer
const token = req.headers.authorization?.slice('Bearer '.length);if (!token) throw new UnauthorizedException();try { const payload = await this.jwt.verifyAsync<JwtPayload>(token, { algorithms: ['HS256'] }); req.user = { id: payload.sub, role: payload.role };} catch { throw new UnauthorizedException();}Why
- Tested: a token signed with the attacker’s key with
role: 'admin'.decodereturns the payload as is;verifyAsyncthrowsinvalid signature. - The
jsonwebtokenREADME:decode“will not verify whether the signature is valid. You should not use this for untrusted messages.” - Pin
algorithmsso unexpected algorithms aren’t accepted; also checkexp,iss,aud. See JWT: what the verifier must check.
CORS origin: true with credentials
Common
app.enableCors({ origin: true, credentials: true }); // "so it works in every environment"Prefer
app.enableCors({ origin: ['https://app.example.com', 'https://admin.example.com'], credentials: true,});Why
origin: truereflects the request’sOriginheader. Tested withOrigin: https://evil.example: the response carriesAccess-Control-Allow-Origin: https://evil.exampleandAllow-Credentials: true.- That means any page can call the API with the victim’s cookies and read the response. The allowlist version returns no
Allow-Originfor unknown origins. - NestJS
enableCorsuses Express’scorspackage, so the options are the same. See Configuring CORS correctly.
Redirecting to a URL taken from the query string
Common
@Get('login/callback')callback(@Query('next') next: string, @Res() res: Response) { // "already blocked": only allow paths starting with / if (!next.startsWith('/')) next = '/'; return res.redirect(next);}Prefer
const APP_ORIGIN = 'https://app.example.com';
function safeNext(next: string | undefined): string { if (!next) return '/'; const url = new URL(next, APP_ORIGIN); if (url.origin !== APP_ORIGIN) return '/'; return url.pathname + url.search;}// res.redirect(safeNext(next))Why
//evil.exampleand/\evil.exampleboth start with/. The WHATWG URL parser (Node’sURL, browsers) reads both ashttps://evil.example/. Tested.- Consequence: your real login link sends users to a phishing page right after they’ve typed their password.
- OWASP recommends mapping server-side ids (
?next=orders) over accepting URLs; if you accept URLs, compare parsed components, not strings.
Secrets and tokens
Section titled “Secrets and tokens”Math.random() for tokens
Common
const resetToken = Math.random().toString(36).slice(2); // ~11 charactersPrefer
import { randomBytes, randomUUID, randomInt } from 'node:crypto';
const resetToken = randomBytes(32).toString('base64url'); // 256 bitsconst inviteId = randomUUID();const otp = randomInt(0, 1_000_000).toString().padStart(6, '0');Why
- MDN:
Math.random()does not provide cryptographically secure random numbers. The resulting token is also short (about 11 characters). - A guessable password reset token means account takeover. Store a hash of the token in the DB, not the token itself.
- Details and demo: Randomness and constant-time comparison.
Comparing secrets with ===
Common
const expected = createHmac('sha256', SECRET).update(rawBody).digest('hex');if (req.headers['x-signature'] !== expected) throw new UnauthorizedException();Prefer
import { createHash, createHmac, timingSafeEqual } from 'node:crypto';
const expected = createHmac('sha256', SECRET).update(rawBody).digest();const given = Buffer.from(String(req.headers['x-signature'] ?? ''), 'hex');// Hash both to 32 bytes: timingSafeEqual throws on different lengthsconst sha = (b: Buffer) => createHash('sha256').update(b).digest();if (!timingSafeEqual(sha(given), sha(expected))) throw new UnauthorizedException();Why
===stops at the first differing byte, so response time leaks how many bytes are correct. An attacker measuring enough times can gradually discover the signature.timingSafeEqualthrowsERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTHwhen the two Buffers differ in length (tested). Either check the length first, or hash to equal length as above.- Applies to webhook signatures, API keys, and tokens compared against stored values. See webhook signatures.
Logging tokens, passwords, personal data
Common
this.logger.log(`login ${JSON.stringify(req.body)}`); // { email, password }this.logger.debug(`calling partner with token ${accessToken}`);logger.info({ req }, 'incoming'); // headers.authorization, cookiePrefer
const logger = pino({ redact: { paths: ['req.headers.authorization', 'req.headers.cookie', 'body.password', '*.token'], censor: '[REDACTED]', },});logger.info({ userId: user.id, route: 'POST /login', outcome: 'success' }, 'auth.login');Why
- Logs are kept for months, readable by many people and systems, and rarely protected like the database. One debug line puts a valid token in the log store.
- The OWASP Logging Cheat Sheet lists what not to record directly: access tokens, session ids, passwords, connection strings, sensitive PII.
- Log ids and outcomes, not payloads. See Logging.
Errors and retries
Section titled “Errors and retries”Returning stack traces and internal errors to the client
Common
@Catch()export class AllExceptionsFilter implements ExceptionFilter { catch(err: any, host: ArgumentsHost) { host.switchToHttp().getResponse().status(500) .json({ message: err.message, stack: err.stack }); // message: duplicate key value violates unique constraint "users_email_key" }}Prefer
@Catch()export class AllExceptionsFilter implements ExceptionFilter { private readonly logger = new Logger('Unhandled'); catch(err: unknown, host: ArgumentsHost) { const res = host.switchToHttp().getResponse(); if (err instanceof HttpException) return res.status(err.getStatus()).json(err.getResponse()); const errorId = randomUUID(); this.logger.error({ errorId, err }); res.status(500).json({ statusCode: 500, message: 'Internal server error', errorId }); }}Why
- Constraint names, SQL statements and file paths help an attacker infer the schema and injection points.
- NestJS’s default filter already returns
{"statusCode":500,"message":"Internal server error"}for unknown errors; the leak usually comes from a hand-written filter. Express withoutNODE_ENV=productionhas its default handler send the fullerr.stack(tested with Express 5). - An
errorIdin the response helps support look up the log. See Errors must not tell internal stories.
Retrying POST without an idempotency key
Common
// client calling the payment service, retrying on timeoutfor (let attempt = 1; attempt <= 3; attempt++) { try { return await http.post('/payments', { orderId, amount }); } catch (err) { if (attempt === 3) throw err; }}Prefer
const key = randomUUID(); // created ONCE, outside the retry loopfor (let attempt = 1; attempt <= 3; attempt++) { try { return await http.post('/payments', { orderId, amount }, { headers: { 'Idempotency-Key': key }, }); } catch (err) { if (attempt === 3 || !isRetryable(err)) throw err; await sleep(backoffWithJitter(attempt)); }}// server: UNIQUE (idempotency_key) + INSERT ... ON CONFLICT, return the previous resultWhy
- A timeout doesn’t mean failure: the request may have completed and only the response was lost. Retrying a POST means charging a second time.
- RFC 9110 classifies POST as non-idempotent. The key lets the server recognize “a retry of the same operation” and return the earlier result.
- The key must be generated outside the loop; generating it inside gives a new key each time, which is useless. Server side: ON CONFLICT, When to retry.
Review checklist
Section titled “Review checklist”- No template strings containing SQL with variables; grep for
raw(,sql.raw,whereRaw,Unsafe,where(`. - A global
ValidationPipewithwhitelist+forbidNonWhitelisted; no@Body() body: any; a separate DTO per use case. - Price, userId, role and state come from the server, not the body.
- Every by-id query has an ownership/tenant condition in the query itself.
- JWTs go through
verify/verifyAsyncwithalgorithms; neverdecodefor authentication. - CORS is a list of specific origins when
credentialsis enabled. - Redirects don’t accept free-form URLs; if they do, parse and compare the
origin. - Tokens are generated with
node:crypto; secrets are compared withtimingSafeEqual. - Logs contain no login bodies,
authorizationheaders, cookies or tokens. - 5xx responses return a generic message +
errorId; production hasNODE_ENV=production. - Every POST retry carries an
Idempotency-Keygenerated once; the server dedupes withUNIQUE.
Next: Performance.