Skip to content
Knowledge Hub

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…

Concatenating strings into SQL through an ORM's raw escape hatch

Common

// Knex
await knex.raw(`SELECT name FROM users WHERE tenant_id = 1 AND name = '${name}'`);
// Kysely
await sql`SELECT name FROM users WHERE tenant_id = 1 AND name = '${sql.raw(name)}'`.execute(db);
// TypeORM QueryBuilder
await 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’s sql tagged template always becomes a parameter; sql.raw does not. The TypeORM docs call concatenating values into where “not safe”.
  • Column names and ASC/DESC can’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

main.ts
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 only whitelist, role is silently dropped; adding forbidNonWhitelisted returns 400 property role should not exist. Tested with @nestjs/common’s ValidationPipe.
  • With @Body() body: any or 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 curl takes seconds. price: 1 buys an iPhone for 1 dong; a different userId places 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 qty with bounds (@IsInt() @Min(1) @Max(100)); negative numbers are another way to get a “discount”.

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/1001 to /orders/1002 reads 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.decode
req.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'. decode returns the payload as is; verifyAsync throws invalid signature.
  • The jsonwebtoken README: decode “will not verify whether the signature is valid. You should not use this for untrusted messages.”
  • Pin algorithms so unexpected algorithms aren’t accepted; also check exp, 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: true reflects the request’s Origin header. Tested with Origin: https://evil.example: the response carries Access-Control-Allow-Origin: https://evil.example and Allow-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-Origin for unknown origins.
  • NestJS enableCors uses Express’s cors package, 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.example and /\evil.example both start with /. The WHATWG URL parser (Node’s URL, browsers) reads both as https://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.

Math.random() for tokens

Common

const resetToken = Math.random().toString(36).slice(2); // ~11 characters

Prefer

import { randomBytes, randomUUID, randomInt } from 'node:crypto';
const resetToken = randomBytes(32).toString('base64url'); // 256 bits
const 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 lengths
const 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.
  • timingSafeEqual throws ERR_CRYPTO_TIMING_SAFE_EQUAL_LENGTH when 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, cookie

Prefer

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.

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 without NODE_ENV=production has its default handler send the full err.stack (tested with Express 5).
  • An errorId in 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 timeout
for (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 loop
for (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 result

Why

  • 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.
  • No template strings containing SQL with variables; grep for raw(, sql.raw, whereRaw, Unsafe, where(`.
  • A global ValidationPipe with whitelist + 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/verifyAsync with algorithms; never decode for authentication.
  • CORS is a list of specific origins when credentials is 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 with timingSafeEqual.
  • Logs contain no login bodies, authorization headers, cookies or tokens.
  • 5xx responses return a generic message + errorId; production has NODE_ENV=production.
  • Every POST retry carries an Idempotency-Key generated once; the server dedupes with UNIQUE.

Next: Performance.