Blog

API security checklist: what to check in every pull request

Eleven checks to run on API handler code before it merges, from object scoping to logs, each with the code a reviewer flags and the fix.

Alex Mercer

An API security checklist for code review covers what every new or changed endpoint has to do before it merges: authenticate and authorize the caller, limit each lookup by ID to records the caller may touch, validate the request at runtime, and keep internal fields, stack traces and secrets out of responses and logs. Most checks map to a risk in the OWASP API Security Top 10, and each shows up as a pattern you can spot in a diff, like a findById(id) with no owner in the query or a tenant ID read from the request body.

The list starts after login. Sessions, JWT validation and password storage have their own security review checklist for PRs touching authentication. For the wider ways AI-written code fails security review, from injection to invented package names, see AI code security: the risks and how to catch them.

API security checklist for pull requests

Run it on every endpoint a pull request adds or changes: REST routes, GraphQL resolvers, RPC methods and Next.js server actions alike. Where a check matches a risk in OWASP's 2023 API list, the risk is in parentheses.

  1. Every non-public endpoint enforces authentication on the server, in the handler or in middleware that covers its route. A check in the page, layout or client doesn't count.

  2. Admin, billing and member-management operations check a role or permission as well as the session. (API5)

  3. Every read, update and delete by ID is scoped to the caller's user, account or tenant. (API1)

  4. Tenant, account and owner IDs come from the session, or are checked against it when they arrive in the path or body. (API1)

  5. Bodies, query parameters and action arguments are validated at runtime against a schema.

  6. Clients can write only the fields they're meant to change, so role, ownerId, price and id stay off the allowlist. (API3)

  7. Responses are built from DTOs, serializers or explicit field lists instead of ORM entities. (API3)

  8. Errors return the right status code and a generic message, with no stack traces. (API8)

  9. Logs, error reports and analytics events carry IDs instead of passwords, tokens, card data or raw bodies.

  10. Public and expensive endpoints have rate limits and caps on payload and page size. (API4)

  11. Tests cover the requests that should fail: no session, another user's record, a role without access. (API1)

Most items come from cubic's review rules for API code. The broadest is Require API Auth, Validation, and Tenant Scoping, which flags endpoints that skip auth, trust a client-supplied tenant or account ID, accept unvalidated bodies or leak internal errors.

Authentication and authorization in every handler

In Next.js, a Server Action is reachable by a direct POST request from outside your UI. The Next.js data security guide also says a page-level authentication check does not extend to the Server Actions defined in that page, so a settings page that redirects signed-out users can still export an action that runs for any caller who sends that POST:

'use server';

import { db } from '@/lib/db';

// Flagged: no session check, no role check, no validation
export async function setMemberRole(memberId: string, role: string) {
  await db.member.update({ where: { id: memberId }, data: { role } });
}
'use server';

import { db } from '@/lib/db';

// Flagged: no session check, no role check, no validation
export async function setMemberRole(memberId: string, role: string) {
  await db.member.update({ where: { id: memberId }, data: { role } });
}
'use server';

import { db } from '@/lib/db';

// Flagged: no session check, no role check, no validation
export async function setMemberRole(memberId: string, role: string) {
  await db.member.update({ where: { id: memberId }, data: { role } });
}
'use server';

import { z } from 'zod';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const RoleChange = z.object({
  memberId: z.string().min(1),
  role: z.enum(['viewer', 'editor', 'admin']),
});

// Passes: session, role, validated input, and a write scoped to the caller's workspace
export async function setMemberRole(memberId: string, role: string) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');
  if (session.user.role !== 'admin') throw new Error('Forbidden');

  const input = RoleChange.parse({ memberId, role });
  const { count } = await db.member.updateMany({
    where: { id: input.memberId, workspaceId: session.user.workspaceId },
    data: { role: input.role },
  });
  if (count === 0) throw new Error('Not found');
}
'use server';

import { z } from 'zod';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const RoleChange = z.object({
  memberId: z.string().min(1),
  role: z.enum(['viewer', 'editor', 'admin']),
});

// Passes: session, role, validated input, and a write scoped to the caller's workspace
export async function setMemberRole(memberId: string, role: string) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');
  if (session.user.role !== 'admin') throw new Error('Forbidden');

  const input = RoleChange.parse({ memberId, role });
  const { count } = await db.member.updateMany({
    where: { id: input.memberId, workspaceId: session.user.workspaceId },
    data: { role: input.role },
  });
  if (count === 0) throw new Error('Not found');
}
'use server';

import { z } from 'zod';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';

const RoleChange = z.object({
  memberId: z.string().min(1),
  role: z.enum(['viewer', 'editor', 'admin']),
});

// Passes: session, role, validated input, and a write scoped to the caller's workspace
export async function setMemberRole(memberId: string, role: string) {
  const session = await auth();
  if (!session?.user) throw new Error('Unauthorized');
  if (session.user.role !== 'admin') throw new Error('Forbidden');

  const input = RoleChange.parse({ memberId, role });
  const { count } = await db.member.updateMany({
    where: { id: input.memberId, workspaceId: session.user.workspaceId },
    data: { role: input.role },
  });
  if (count === 0) throw new Error('Not found');
}

TypeScript annotations never change runtime behavior, so role: string accepts whatever the caller sends until a schema checks it. The full rule is Authorize and Validate Every Next.js Server Action.

In Spring Boot, authorizeHttpRequests applies only the first rule that matches a request, according to the Spring Security docs, so a broad permitAll() added for one public route opens everything under it:

// Flagged: /api/** matches first, so the admin rule never runs
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/**").permitAll()
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated());

// Passes: the public route is matched exactly
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/status").permitAll()
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated());
// Flagged: /api/** matches first, so the admin rule never runs
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/**").permitAll()
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated());

// Passes: the public route is matched exactly
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/status").permitAll()
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated());
// Flagged: /api/** matches first, so the admin rule never runs
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/**").permitAll()
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated());

// Passes: the public route is matched exactly
http.authorizeHttpRequests(auth -> auth
    .requestMatchers("/api/status").permitAll()
    .requestMatchers("/api/admin/**").hasRole("ADMIN")
    .anyRequest().authenticated());

Method security has a similar gap. @PreAuthorize is enforced only when method security is turned on, usually with @EnableMethodSecurity on a configuration class, and Spring Boot's security starter doesn't turn it on by default. Require Authorization on Every Spring Boot Endpoint covers both, plus lookups by ID with no ownership check.

Object-level authorization: scope every lookup by ID

Broken object level authorization (BOLA) is first on OWASP's API list, and OWASP's cheat sheets describe the same pattern as an insecure direct object reference (IDOR). The endpoint confirms that someone is signed in, then acts on whatever ID the request names. OWASP's API1 page asks for an authorization check in every function that uses client input to access a record. One of its example attacks deletes another user's document through a GraphQL mutation that takes the document ID and checks nothing else, and bulk endpoints have the same gap:

// Flagged: deletes every report whose ID is in the body, in any workspace
await db.report.deleteMany({ where: { id: { in: input.ids } } });

// Passes: IDs from another workspace match nothing
await db.report.deleteMany({
  where: { id: { in: input.ids }, workspaceId: session.user.workspaceId },
});
// Flagged: deletes every report whose ID is in the body, in any workspace
await db.report.deleteMany({ where: { id: { in: input.ids } } });

// Passes: IDs from another workspace match nothing
await db.report.deleteMany({
  where: { id: { in: input.ids }, workspaceId: session.user.workspaceId },
});
// Flagged: deletes every report whose ID is in the body, in any workspace
await db.report.deleteMany({ where: { id: { in: input.ids } } });

// Passes: IDs from another workspace match nothing
await db.report.deleteMany({
  where: { id: { in: input.ids }, workspaceId: session.user.workspaceId },
});

For a single record in Spring Data, the same scope is a derived query such as findByIdAndAccountId(id, user.getAccountId()) in place of findById(id).

Django REST framework's IsAuthenticated denies unauthenticated users and allows everyone else, so it says nothing about which rows a user may touch. Filter get_queryset() by the user or organization, and load objects in custom actions with self.get_object(), which applies that queryset and the object permissions. Scope Django Querysets to the Current User or Tenant flags a bare Ticket.objects.get(pk=pk) that skips both.

In Rails, Document.find(params[:id]) returns any row, and the Rails security guide recommends going through the user instead, as in current_user.documents.find(params[:id]).

Input validation and tenant IDs from the session

A handler that reads the organization or workspace ID from the request lets any caller write into any tenant. It also passes every happy-path test, because the tester sends their own IDs. The fix takes the tenant from the session and parses the body with a schema:

// app/api/folders/route.ts
// Flagged: no session, no validation, and the caller picks the workspace
export async function POST(req: Request) {
  const body = await req.json();
  const folder = await db.folder.create({
    data: { name: body.name, workspaceId: body.workspaceId },
  });
  return Response.json(folder);
}
// app/api/folders/route.ts
// Flagged: no session, no validation, and the caller picks the workspace
export async function POST(req: Request) {
  const body = await req.json();
  const folder = await db.folder.create({
    data: { name: body.name, workspaceId: body.workspaceId },
  });
  return Response.json(folder);
}
// app/api/folders/route.ts
// Flagged: no session, no validation, and the caller picks the workspace
export async function POST(req: Request) {
  const body = await req.json();
  const folder = await db.folder.create({
    data: { name: body.name, workspaceId: body.workspaceId },
  });
  return Response.json(folder);
}
// app/api/folders/route.ts
// Passes
import { z } from 'zod';
import { getSession } from '@/lib/auth';
import { db } from '@/lib/db';

const NewFolder = z.object({ name: z.string().trim().min(1).max(100) });

export async function POST(req: Request) {
  const session = await getSession();
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 });

  const input = NewFolder.safeParse(await req.json().catch(() => null));
  if (!input.success) return Response.json({ error: 'Invalid request body' }, { status: 400 });

  const folder = await db.folder.create({
    data: { name: input.data.name, workspaceId: session.workspaceId },
  });
  return Response.json(folder, { status: 201 });
}
// app/api/folders/route.ts
// Passes
import { z } from 'zod';
import { getSession } from '@/lib/auth';
import { db } from '@/lib/db';

const NewFolder = z.object({ name: z.string().trim().min(1).max(100) });

export async function POST(req: Request) {
  const session = await getSession();
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 });

  const input = NewFolder.safeParse(await req.json().catch(() => null));
  if (!input.success) return Response.json({ error: 'Invalid request body' }, { status: 400 });

  const folder = await db.folder.create({
    data: { name: input.data.name, workspaceId: session.workspaceId },
  });
  return Response.json(folder, { status: 201 });
}
// app/api/folders/route.ts
// Passes
import { z } from 'zod';
import { getSession } from '@/lib/auth';
import { db } from '@/lib/db';

const NewFolder = z.object({ name: z.string().trim().min(1).max(100) });

export async function POST(req: Request) {
  const session = await getSession();
  if (!session) return Response.json({ error: 'Unauthorized' }, { status: 401 });

  const input = NewFolder.safeParse(await req.json().catch(() => null));
  if (!input.success) return Response.json({ error: 'Invalid request body' }, { status: 400 });

  const folder = await db.folder.create({
    data: { name: input.data.name, workspaceId: session.workspaceId },
  });
  return Response.json(folder, { status: 201 });
}

Zod's z.object() strips keys the schema doesn't declare, so a workspaceId in the body never reaches the database. z.strictObject() rejects the request instead.

In Spring, look for a request DTO whose constraint annotations never run because the parameter lacks @Valid. Spring applies Bean Validation to a @RequestBody only when it's marked @Valid or @Validated, and a failed check then becomes a 400 response by default (Spring Framework docs).

Mass assignment and excessive data exposure

OWASP's API3, Broken Object Property Level Authorization, combines two risks from the 2019 list, excessive data exposure and mass assignment (API3:2023). Both show up when a controller uses the ORM entity as its request or response type:

// Flagged: the JSON can set id, role and passwordHash, and the response returns every column
@PostMapping("/api/signup")
public User signUp(@RequestBody User user) {
    return users.save(user);
}

// Passes: a validated request DTO in, a two-field response DTO out
public record SignUpRequest(
        @NotBlank @Email String email,
        @NotBlank @Size(min = 12, max = 128) String password) {}

public record UserView(Long id, String email) {}

@PostMapping("/api/signup")
@ResponseStatus(HttpStatus.CREATED)
public UserView signUp(@Valid @RequestBody SignUpRequest request) {
    User user = userService.register(request.email(), request.password());
    return new UserView(user.getId(), user.getEmail());
}
// Flagged: the JSON can set id, role and passwordHash, and the response returns every column
@PostMapping("/api/signup")
public User signUp(@RequestBody User user) {
    return users.save(user);
}

// Passes: a validated request DTO in, a two-field response DTO out
public record SignUpRequest(
        @NotBlank @Email String email,
        @NotBlank @Size(min = 12, max = 128) String password) {}

public record UserView(Long id, String email) {}

@PostMapping("/api/signup")
@ResponseStatus(HttpStatus.CREATED)
public UserView signUp(@Valid @RequestBody SignUpRequest request) {
    User user = userService.register(request.email(), request.password());
    return new UserView(user.getId(), user.getEmail());
}
// Flagged: the JSON can set id, role and passwordHash, and the response returns every column
@PostMapping("/api/signup")
public User signUp(@RequestBody User user) {
    return users.save(user);
}

// Passes: a validated request DTO in, a two-field response DTO out
public record SignUpRequest(
        @NotBlank @Email String email,
        @NotBlank @Size(min = 12, max = 128) String password) {}

public record UserView(Long id, String email) {}

@PostMapping("/api/signup")
@ResponseStatus(HttpStatus.CREATED)
public UserView signUp(@Valid @RequestBody SignUpRequest request) {
    User user = userService.register(request.email(), request.password());
    return new UserView(user.getId(), user.getEmail());
}

Returning the entity also exposes any column added later, with no change to the controller. The full rule is Validate Request DTOs and Stop Exposing Entities in Spring Boot.

Rails blocks mass assignment of parameters nobody permitted, but the developer still writes the permit list (strong parameters). A permitted owner_id or role lets any user reassign records or promote themselves:

def document_params
  # Flagged: owner_id lets the client move the document to another user
  params.expect(document: [:title, :body, :owner_id])
end

def document_params
  # Passes: only the fields a user edits
  params.expect(document: [:title, :body])
end
def document_params
  # Flagged: owner_id lets the client move the document to another user
  params.expect(document: [:title, :body, :owner_id])
end

def document_params
  # Passes: only the fields a user edits
  params.expect(document: [:title, :body])
end
def document_params
  # Flagged: owner_id lets the client move the document to another user
  params.expect(document: [:title, :body, :owner_id])
end

def document_params
  # Passes: only the fields a user edits
  params.expect(document: [:title, :body])
end

The Rails guide warns that params.permit! allows all current and future model attributes to be mass-assigned. In JavaScript, the same bug is Object.fromEntries(formData) passed straight into an ORM create or update. Block Mass Assignment and Unscoped Finds in Rails flags the Rails forms, along with unscoped Model.find(params[:id]) lookups.

Error handling and status codes

OWASP lists error messages that include stack traces or other sensitive information under API8:2023 Security Misconfiguration, and its REST Security Cheat Sheet says to respond with generic error messages and pass no call stacks to the client. Log the detail on the server and return a status the client can act on:

Status

Return it when

400

The body or parameters fail validation

401

The request has no valid session or token

403

The caller is signed in but lacks the role or permission

404

The record doesn't exist or belongs to another user or tenant

429

The caller is over a rate limit

For another tenant's record, a 404 keeps the response from confirming that the ID exists. RFC 9110 allows a server to answer 404 instead of 403 when it wants to hide that a forbidden resource exists.

Sensitive data in logs

Log lines end up in more systems than the database does, such as log aggregators, error trackers and backups, where access is usually broader and retention longer. In API code, the leak tends to be a debugging line that dumps headers or the request body:

def create_refund(request, account, order):
    # Flagged: the bearer token and the raw body reach every log sink
    logger.info(
        "refund request auth=%s body=%s",
        request.headers.get("Authorization"),
        request.body,
    )
def create_refund(request, account, order):
    # Flagged: the bearer token and the raw body reach every log sink
    logger.info(
        "refund request auth=%s body=%s",
        request.headers.get("Authorization"),
        request.body,
    )
def create_refund(request, account, order):
    # Flagged: the bearer token and the raw body reach every log sink
    logger.info(
        "refund request auth=%s body=%s",
        request.headers.get("Authorization"),
        request.body,
    )
def create_refund(request, account, order):
    # Passes: IDs only
    logger.info("refund requested account_id=%s order_id=%s", account.id, order.id)
def create_refund(request, account, order):
    # Passes: IDs only
    logger.info("refund requested account_id=%s order_id=%s", account.id, order.id)
def create_refund(request, account, order):
    # Passes: IDs only
    logger.info("refund requested account_id=%s order_id=%s", account.id, order.id)

OWASP's Logging Cheat Sheet lists data to remove, mask, hash or encrypt before an event is recorded, including session IDs, access tokens, passwords, database connection strings, encryption keys and payment card data. The 2023 API list has no logging category; the web application OWASP Top 10 covers it as A09:2025 Security Logging and Alerting Failures. The review rule is Prevent Sensitive Data in Logs.

Rate limiting and payload size limits

API4:2023 Unrestricted Resource Consumption asks for rate limits, maximum sizes for strings, arrays and uploads, and server-side validation of the parameter that sets how many records a response returns. In review, look for a new public or expensive endpoint with no rate limit, and for a limit or pageSize parameter with no cap:

const ListQuery = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
});
const ListQuery = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
});
const ListQuery = z.object({
  limit: z.coerce.number().int().min(1).max(100).default(20),
});

When a caller goes over the limit, the OWASP REST Security Cheat Sheet recommends a 429 Too Many Requests response.

Authorization tests for the denied paths

Scoping bugs pass CI when every test signs in as the user who owns every fixture, and a misordered rule passes when no test calls the endpoint with the wrong role. Add a test for each request that should fail. For the Spring configuration earlier, a signed-in user without the admin role should get a 403:

@Test
@WithMockUser // a signed-in user with only ROLE_USER
void nonAdminGets403OnAdminRoutes() throws Exception {
    mockMvc.perform(get("/api/admin/users"))
        .andExpect(status().isForbidden());
}
@Test
@WithMockUser // a signed-in user with only ROLE_USER
void nonAdminGets403OnAdminRoutes() throws Exception {
    mockMvc.perform(get("/api/admin/users"))
        .andExpect(status().isForbidden());
}
@Test
@WithMockUser // a signed-in user with only ROLE_USER
void nonAdminGets403OnAdminRoutes() throws Exception {
    mockMvc.perform(get("/api/admin/users"))
        .andExpect(status().isForbidden());
}

The test fails against the flagged rules, where /api/** is permitAll(), and passes once the admin rule applies. Add the same kind of test for another account's record, expecting a 404, and for a request with no session. OWASP's API1 page gives the same advice in general terms: write tests for the authorization mechanism, and don't deploy changes that make them fail.

Running the checklist on every pull request

The same checks apply to every endpoint, so AI code review can apply these rules on every pull request instead of a reviewer repeating them by hand. The checks cover handler code only. Dependency scanning, SAST, DAST and the review process around them are in the secure code review guide.

AI-written handlers need the same checks. On Bug Bench, which ranks AI coding models by the bugs cubic finds in the code they write, the relative rate of security and privacy bugs runs from 0.56 for GPT-5.6 Sol to 1.50 for Claude Opus 5.5, where 1.00 is the all-model rate and lower is better. Bug Bench is observational: teams choose their models, so a rate reflects the code each model was used for as well as the model.

FAQ

What are the top 10 API security risks according to OWASP?

The latest edition on OWASP's API Security site is from 2023: API1 Broken Object Level Authorization, API2 Broken Authentication, API3 Broken Object Property Level Authorization, API4 Unrestricted Resource Consumption, API5 Broken Function Level Authorization, API6 Unrestricted Access to Sensitive Business Flows, API7 Server Side Request Forgery, API8 Security Misconfiguration, API9 Improper Inventory Management and API10 Unsafe Consumption of APIs. OWASP's release notes say the API list does not replace its other Top 10 lists.

What are some common API security vulnerabilities?

Broken object level authorization leads OWASP's API list, which rates it easy to exploit and widespread. OWASP also warns that comparing the user ID in the session with the ID parameter isn't a sufficient fix, because it covers only a small subset of cases. Mass assignment and responses that return too many fields, both part of API3, are rated easy to exploit and common.

What are the main security standards for APIs?

The OWASP API Security Top 10 names what OWASP calls the ten most critical API security risks, with prevention steps for each. OWASP's Application Security Verification Standard, whose latest stable version is 5.0.0, gives developers a list of requirements for secure development and gives testers a basis for checking technical security controls. NIST SP 800-228, published in June 2025, recommends controls for APIs in cloud-native systems, both before and during runtime.

What are the main API security best practices?

Outside handler code, OWASP's API list asks for TLS on all API traffic, internal APIs included, and an inventory of hosts and deployed API versions so deprecated versions and debug endpoints don't stay exposed. It also asks for spending limits or billing alerts on paid third-party integrations, and for validating data from integrated APIs before using it.

Table of contents