REST API Security: Threats, Controls, and Checklist

REST API security is the set of controls that protects HTTP resources and business actions from unauthorized access, manipulation, abuse, and leakage. Secure every request with TLS, validated identity, object and function level authorization, strict input and output schemas, bounded resource use, safe errors and audit-ready logging. Then test those controls across users, tenants, roles, methods, states and API versions, not only with happy-path requests. The control list below follows the OWASP REST Security Cheat Sheet and the OWASP API Security Top 10 2023, both read 19 September 2026.
Qodex checks these controls for you: attack chains run against your preview on every pull request, from flows it has already tested, with two real accounts. See Qodex security testing.
REST API security in one table
Each row is one risk from the OWASP API Security Top 10 2023, the request that exposes it, the control that stops it, the test that proves the control works, and where each belongs. A row with no test in your repository is a hope, not a control.
| OWASP 2023 risk | Failing request | Control | Test | Enforce in | Log |
|---|---|---|---|---|---|
| API1 Object level | Swap an ID for another tenant's | Scope the query by tenant and owner | Two accounts, cross-read returns 404 | Service | Denied object reads |
| API2 Authentication | Expired or wrong-issuer token accepted | Verify signature, issuer, audience, expiry | Tampered and expired tokens return 401 | Gateway and service | Token failures |
| API3 Property level | Client sets a role or price field | Allowlist input and output fields | Extra field is ignored, not echoed | Service | Rejected fields |
| API4 Resource use | Unbounded page size or upload | Limits on size, page, cost, concurrency | Oversized body gets 413, flood gets 429 | Gateway | 413 and 429 counts |
| API5 Function level | A member calls an admin route | Deny by default, check role per route | Low-privilege call returns 403 | Service | Denied routes |
| API6 Business flows | Scripted signup or refund loop | Per-action quotas and step ordering | Repeat action is refused after quota | Service | Flow counters |
| API7 SSRF | User URL fetched by the server | Allowlist hosts, block private ranges | Internal address is refused | Service | Outbound targets |
| API8 Misconfiguration | Debug route live in production | Config as code, staged review | Management routes 404 in production | Infra | Config changes |
| API9 Inventory | Old v1 host still answering | One inventory of hosts and versions | Sweep finds no undocumented route | Infra | New hosts seen |
| API10 Upstream use | Blind trust of a partner response | Validate upstream data and timeouts | Malformed upstream response is rejected | Service | Upstream errors |
Risk names come from the OWASP API Security Top 10 2023, read 19 September 2026. Requests, tests, and enforcement points are this page's recommendation.
REST API threats mapped to OWASP 2023
REST itself decides very little about security. Fielding writes that "communication must be stateless in nature", which pushes identity into every request rather than into a server-side session (Fielding, chapter 5, read 19 September 2026). Nothing in that style says who may read invoice 412. You decide that, per request, in code you own.
The table above carries all ten categories of the OWASP API Security Top 10 2023, read 19 September 2026. Five of them shape the rest of this page and are worth expanding here. The other five, API2 authentication, API7 request forgery, API8 misconfiguration, API9 inventory and API10 upstream data, get their exploit and fix detail in the OWASP post linked at the end of this section.
API1, broken object level authorization. A real user with a real token asks for a record belonging to someone else, and changing a path ID is the whole attack. The fix is a query carrying the caller's tenant and user.
API3, broken object property level authorization. The object is right and the fields are not: a client sends
roleordiscount_centsand the ORM writes it, or a response serializes the whole row.API4, unrestricted resource consumption. One caller asks for an unbounded page, uploads an unbounded file, or fans a single request out into many database reads. The service stays up for the attacker and falls over for everyone else.
API5, broken function level authorization. A route a role should not reach, guarded by the frontend hiding a button and by nothing on the server.
API6, unrestricted access to sensitive business flows. Every request is valid and the sequence is the attack: scripted signups, bulk ticket purchase, repeated refunds. Rate limiting by IP does not see this.
API1, API3, and API5 are the same sentence in different clothes: the server did not check what this caller may touch. API6 survives a perfect authorization layer, because each individual request passes every check. Treat the first group as code inside the owning service, and the second as counters on business actions rather than on HTTP requests. The rest of this page builds those controls.
For the ten risks with worked exploit and fix detail, see OWASP API Top 10 tests and fixes. For the wider category and its vocabulary, see API security foundations.
Authentication and authorization on every request
Authentication answers who is calling. Authorization answers what this caller may do to this thing right now. A REST API needs both on every request, because no server-side session holds the answer from last time.
OWASP is direct about the first half: non-public REST services must perform access control at each API endpoint. In a service-to-service architecture that decision should be taken locally by the endpoint, while user authentication is centralised in an identity provider that issues access tokens (OWASP REST Security Cheat Sheet, read 19 September 2026). A gateway can reject a request with no token at all. It cannot know that invoice 412 belongs to tenant B.
Validating a JWT. The same cheat sheet lists what to check. Ensure the token is integrity protected by a signature or a MAC, and do not allow unsecured tokens with {"alg":"none"}. Never use the token header to select the verification algorithm, because that lets the caller choose. Then verify four standard claims: iss is a trusted issuer, aud includes your service, exp has not passed and nbf has. A token passing all four still says nothing about object ownership.
OAuth 2.0. The current security profile is RFC 9700, Best Current Practice for OAuth 2.0 Security, published January 2025 and read 19 September 2026. It updates RFC 6749, 6750, and 6819, and covers PKCE, audience-restricted and sender-constrained access tokens, and refresh token protection. If you consume tokens rather than issue them, audience restriction and sender constraint shrink what a stolen token is worth.
API keys. OWASP treats them as a throttling and identification tool, revocable when a client breaks its usage agreement. But keys issued to third-party clients are relatively easy to compromise, so do not rely exclusively on API keys to protect sensitive, critical, or high-value resources (same cheat sheet, read 19 September 2026).
The authorization ladder. Once identity is settled, walk down these checks in order, and fail the request at the first one that says no.
Route. Does this token's scope or role reach this method and path? Deny by default.
Tenant. Does the record belong to the organization in the token, rather than to an organization ID in the request body?
Object. Does this user own or share this record? Put the answer in the query, not in an
ifafter the row is loaded.Property. May this user read or write this field? Two roles can share an endpoint and differ at field level.
Workflow state. Is this action legal from the object's current state? A shipped order should not accept an address change.
Use 401 when authentication is missing or invalid, and 403 when identity is valid but permission is absent. For cross-tenant reads, prefer 404: a 403 confirms the record exists.
Validate input, output, and content types
Validation has two layers that people collapse into one. The structural layer asks whether the request is the shape you documented; a schema library gives you that. The semantic layer asks whether it means something legal for this caller, and only your code gives you that.
OWASP's input rules for REST are specific. Validate length, range, format and type. Constrain inputs where you can, and prefer allowlists to blocklists. Define a request size limit and reject anything over it with 413. Log validation failures, because a sudden burst of them is a signal. Match the body to the content type in the header, and reject an unexpected or missing content type using 406 or 415 (OWASP REST Security Cheat Sheet, read 19 September 2026).
Four habits carry the rest:
Allowlist the fields you accept. Bind the request to an explicit input type with a fixed field list. Mass assignment from a parsed body into an ORM object is how API3 happens.
Allowlist the fields you return. Build the response from a named shape rather than serializing the database row. The worked example below leaves an internal note where it belongs.
Parameterize every query. Bound parameters, not string concatenation.
Validate what comes back from other services. Give partner and internal responses the same schema check and size cap as a browser request.
Semantic rules live next to the business logic: a refund cannot exceed the payment, a discount code has to be live, a date range has to be forward in time. A schema cannot express these.
Rate limits and resource bounds
API4 is about the cost of a request, not its legality. Bound the cost in several dimensions at once, because a limit on request count alone is easy to route around.
Requests per identity. Key limits on the authenticated user, the tenant, and the API key, with IP as a fallback for unauthenticated routes. Shared office addresses make IP-only limits leaky and unfair.
Payload and page size. A hard body limit returning 413, a maximum page size the server enforces even when the client asks for more, and a cap on array lengths inside the body.
Query cost. Weight expensive endpoints more heavily. A report that scans a year of rows should not share a budget with a health check.
Concurrency and timeouts. Cap in-flight requests per caller, and give every outbound call a timeout.
Return 429 Too Many Requests when a client has sent too many requests in a given amount of time (RFC 6585, read 19 September 2026), and send Retry-After with it. That header takes either an HTTP date or a number of seconds (RFC 9110, section 10.2.3, read 19 September 2026). Give the client a number it can act on rather than leaving it to guess and retry in a loop.
Rate limits do not solve API6. Counting business actions does: signups per device, refunds per account, tickets per payment method. Those counters sit in the service that owns the action.
Protect transport and secrets
TLS is settled ground. RFC 9325, read 19 September 2026, says implementations must not negotiate SSL 2, SSL 3, TLS 1.0, or TLS 1.1, and must support TLS 1.2. They should support TLS 1.3 and, where implemented, must prefer it over earlier versions. It also calls itself a floor, not a ceiling. So: TLS 1.3 preferred, TLS 1.2 kept for clients that need it, everything older switched off at the load balancer and verified from the outside.
For high-value internal traffic, OWASP suggests mutually authenticated client-side certificates as additional protection.
Secrets need the same care. OWASP is explicit that passwords, security tokens, and API keys should not appear in the URL, because web server logs capture them. Send sensitive data in the request body or a header instead. Beyond that: no secrets in source control, rotation after any suspected exposure, and separate credentials per environment so a staging leak cannot read production.
Safe errors, logging, and detection
Errors are an information channel. OWASP's rule is to respond with generic error messages and to avoid revealing details of the failure unnecessarily. No technical details such as call stacks or other internal hints go to the client (OWASP REST Security Cheat Sheet, read 19 September 2026). Keep the detail on the server, attach a correlation ID to both sides, and let support match the two.
The same page sets three logging rules. Write audit logs before and after security related events. Consider logging token validation errors so you can spot attacks. Sanitize log data first, to defend against log injection.
Decide what an audit event is before you build the dashboard. A usable minimum:
Authentication outcomes. Success and failure, with the reason class, never the token itself.
Authorization denials. Who, which object or route, which check failed. A spike here is somebody walking IDs.
Sensitive actions. Role changes, exports, refunds, deletions, key issuance and revocation.
Limit and configuration events. 413, 415 and 429 by caller, and who changed a CORS origin, a flag or a scope.
Redact before the write, not after. Tokens, passwords, card numbers and full request bodies do not belong in a log line. A pipeline that scrubs downstream still leaves the raw value on the first disk it touched.
Then make the detection testable. Send a request that should be denied and assert an event appears with the right fields. An alert nobody has seen fire is a configuration file, not a control.
Broken and fixed request-level control
Here is API1 and API3 in one small service, and the test that proves the fix. It is a Node and Express API over SQLite with two tenants, two invoices and a valid token for each of the two users. The broken handler authenticates the caller and then loads whatever ID the path carries. The fixed handler puts the caller's tenant and user ID into the query, and builds the response from a named field list.
This is demonstration code, not a production service. The store is in-memory SQLite that empties when the process stops, tokens are signed with HS256 and a throwaway shared secret rather than an issuer's key, and the claims are hard-coded instead of arriving from a login. clockTolerance and expiresIn below are demo choices, not recommended values: set both from your own issuer and clock-skew policy.
Install the two dependencies in an empty directory with npm install express@5 jsonwebtoken@9, set "type": "module" in package.json, and save the file as server.js. The SQLite driver is built into Node. In our run that resolved Express 5.2.1 and jsonwebtoken 9.0.3 on Node 26.9.0.
import express from 'express';
import jwt from 'jsonwebtoken';
import { DatabaseSync } from 'node:sqlite';
const SECRET = process.env.JWT_SECRET;
const ISSUER = 'https://auth.example.com';
const AUDIENCE = 'invoices-api';
const db = new DatabaseSync(':memory:');
db.exec(`
CREATE TABLE invoices (
id TEXT PRIMARY KEY,
tenant_id TEXT NOT NULL,
owner_id TEXT NOT NULL,
amount_cents INTEGER NOT NULL,
internal_risk_note TEXT NOT NULL
);
`);
const seed = db.prepare('INSERT INTO invoices VALUES (?, ?, ?, ?, ?)');
seed.run('inv_alice', 'tenant_a', 'user_alice', 4200, 'flagged for manual review');
seed.run('inv_bob', 'tenant_b', 'user_bob', 9900, 'do not disclose');
// Demo only: a real issuer mints these. expiresIn is a demo choice.
export function mintToken(sub, tenantId) {
return jwt.sign({ sub, tenant_id: tenantId }, SECRET, {
algorithm: 'HS256',
issuer: ISSUER,
audience: AUDIENCE,
expiresIn: '5m'
});
}
function requireToken(req, res, next) {
const header = req.get('authorization') || '';
const token = header.startsWith('Bearer ') ? header.slice(7) : null;
if (!token) return res.status(401).json({ error: 'unauthenticated' });
try {
req.auth = jwt.verify(token, SECRET, {
algorithms: ['HS256'],
issuer: ISSUER,
audience: AUDIENCE,
clockTolerance: 5
});
return next();
} catch {
return res.status(401).json({ error: 'unauthenticated' });
}
}
const FIND_INVOICE =
'SELECT id, amount_cents FROM invoices WHERE id = ? AND tenant_id = ? AND owner_id = ?';
const app = express();
// Broken: authenticated, but not authorized for this object.
app.get('/v1/invoices/:id', requireToken, (req, res) => {
const row = db.prepare('SELECT * FROM invoices WHERE id = ?').get(req.params.id);
if (!row) return res.status(404).json({ error: 'not_found' });
return res.json(row);
});
// Fixed: tenant and owner come from the verified token, never from the request.
app.get('/v2/invoices/:id', requireToken, (req, res) => {
const row = db
.prepare(FIND_INVOICE)
.get(req.params.id, req.auth.tenant_id, req.auth.sub);
if (!row) return res.status(404).json({ error: 'not_found' });
return res.json({ id: row.id, amount_cents: row.amount_cents });
});
export default app;
The difference is one line of SQL and one line of serialization. The fixed handler returns 404, not 403, when the row exists in another tenant, so Alice learns nothing about whether inv_bob is real.
Now the test, saved as authz.test.js beside the server. The first case asserts that the broken route leaks, so the file documents the bug as well as the fix.
import test from 'node:test';
import assert from 'node:assert/strict';
import app, { mintToken } from './server.js';
const alice = mintToken('user_alice', 'tenant_a');
const bob = mintToken('user_bob', 'tenant_b');
const server = app.listen(0);
const base = `http://127.0.0.1:${server.address().port}`;
const get = (path, token) =>
fetch(base + path, { headers: token ? { authorization: `Bearer ${token}` } : {} });
test.after(() => server.close());
test('broken handler leaks another tenant invoice', async () => {
const res = await get('/v1/invoices/inv_bob', alice);
assert.equal(res.status, 200);
assert.equal((await res.json()).owner_id, 'user_bob');
});
test('each owner reads their own invoice', async () => {
const mine = await get('/v2/invoices/inv_alice', alice);
assert.equal((await mine.json()).amount_cents, 4200);
const theirs = await get('/v2/invoices/inv_bob', bob);
assert.equal((await theirs.json()).amount_cents, 9900);
});
test('cross-tenant read returns 404 both ways', async () => {
assert.equal((await get('/v2/invoices/inv_bob', alice)).status, 404);
assert.equal((await get('/v2/invoices/inv_alice', bob)).status, 404);
});
test('missing token returns 401', async () => {
const res = await get('/v2/invoices/inv_alice');
assert.equal(res.status, 401);
});
test('internal field never leaves the service', async () => {
const body = await (await get('/v2/invoices/inv_alice', alice)).json();
assert.equal('internal_risk_note' in body, false);
});
Run it with a throwaway secret. This is our run on Node 26.9.0, with the repeated Subtest lines, the per-test timing blocks and the trailing duration removed, because those numbers change on every run.
$ JWT_SECRET=dev-only-secret node --test --test-reporter=tap
TAP version 13
ok 1 - broken handler leaks another tenant invoice
ok 2 - each owner reads their own invoice
ok 3 - cross-tenant read returns 404 both ways
ok 4 - missing token returns 401
ok 5 - internal field never leaves the service
1..5
# tests 5
# suites 0
# pass 5
# fail 0
To see it by hand, save this as demo.js. It starts the service and prints both tokens as shell assignments.
import app, { mintToken } from './server.js';
app.listen(4010, () => {
console.log('ALICE=' + mintToken('user_alice', 'tenant_a'));
console.log('BOB=' + mintToken('user_bob', 'tenant_b'));
});
$ JWT_SECRET=dev-only-secret node demo.js > tokens.txt &
$ until grep -q BOB= tokens.txt; do sleep 0.2; done
$ eval "$(cat tokens.txt)"
$ curl -s -H "Authorization: Bearer $ALICE" localhost:4010/v2/invoices/inv_alice
{"id":"inv_alice","amount_cents":4200}
$ curl -s -w '\nHTTP %{http_code}\n' -H "Authorization: Bearer $ALICE" localhost:4010/v2/invoices/inv_bob
{"error":"not_found"}
HTTP 404
Bob's own token reads inv_bob from that same route and gets {"id":"inv_bob","amount_cents":9900}, so the 404 above is a permission boundary and not a missing record. On the broken /v1 route Alice's token returns Bob's whole row, internal_risk_note included.
Copy four of those five assertions into your own suite for every owned resource: the owner reads it, a second account does not, no token fails, and the private field never appears. Two accounts in two organizations is the smallest setup that can catch a cross-tenant read.
REST API security checklist
A short implementation pass over one service. Each line is phrased so the answer is yes or no, and each has a test you can write.
Transport. TLS 1.3 preferred, TLS 1.2 kept only for clients that need it, SSL 2, SSL 3, TLS 1.0, and TLS 1.1 refused, checked from outside rather than from the config file.
Identity. Every non-public route needs a token. Signature, issuer, audience, expiry, and not-before verified, with the algorithm fixed by the server.
Authorization. Deny by default per route. Tenant and owner in the data query. Field-level read and write rules. Workflow state checked before state changes.
Input and output. A field allowlist per endpoint, parameterized queries, a body size limit returning 413, the content type validated returning 415, and responses built from a named shape.
Limits. Per user, tenant, and key, weighted by endpoint cost. Server-enforced page size. A timeout on every outbound call. 429 with
Retry-After. Counters on the business actions worth abusing.Errors, logs, and configuration. Generic client errors with no stack traces. Audit events sanitized and redacted at write time. Management routes unreachable in production, CORS origins named, no credentials in URLs.
Tests keep this list true next quarter, so put the two-account authorization cases in the suite that blocks a merge. Host and version inventory, secret rotation and the other operational items have per-item guidance in the full API security checklist, and the API security testing process covers testing these controls on a running service. Vulnerability scanning tools find known issues and misconfiguration; business-logic flaws need penetration testing or a test you write yourself.
One caution on compliance. Regulations set duties, not endpoint checklists. GDPR Article 32 requires measures appropriate to the risk. It names pseudonymisation and encryption, continuing confidentiality, integrity, availability and resilience, restoration after an incident, and a process for regularly testing the measures (GDPR Article 32, read 19 September 2026). Passing the tests above produces evidence a compliance programme can use. It does not by itself make you compliant with GDPR, PCI DSS or SOC 2.
The short version
REST gives you a request shape and nothing else. Identity, object ownership, field rules, cost limits and audit trails are yours to build, per request, in the service that owns the data. A gateway can reject a malformed or unauthenticated call cheaply, but it cannot know who owns invoice 412. Test every control with two accounts, and treat any row in the first table with no test as work still to do.
Frequently Asked Questions
What is REST API security?
It is the set of controls protecting HTTP resources and business actions against unauthorized access, manipulation, abuse and leakage: TLS, verified identity, authorization checked per object and per function, validated input and output, bounded resource use, safe errors and audit logs. REST supplies architectural constraints, not an authentication or authorization model.
Is REST more or less secure than SOAP?
Neither is automatically safer. They are different styles with different security models, and neither ranks above the other in the abstract. REST supplies architectural constraints and no authentication or authorization model, so it relies on the transport and on whatever the service implements. Real-world risk is decided by the same things in both: identity handling, authorization on each request, input validation, and operational discipline.
What is the difference between authentication and authorization in a REST API?
Authentication establishes who is calling, by validating a token or credential. Authorization decides what that caller may do to a specific resource right now, covering the route, the tenant, the object, the fields and the workflow state. A request can pass authentication and still have to be refused, which is what the broken handler above gets wrong.
Are API keys enough to secure a REST API?
No. OWASP describes API keys as a way to identify and meter clients, and notes that keys issued to third-party clients are relatively easy to compromise. Its guidance is not to rely exclusively on API keys to protect sensitive, critical, or high-value resources (OWASP REST Security Cheat Sheet, read 19 September 2026). Use them for throttling and client identity, and authenticate users with tokens you verify.
How do you prevent BOLA or IDOR in REST APIs?
Take the tenant and the user from the verified token, never from the request, and push both into the data query so an unauthorized row is never loaded. Return 404 rather than 403 on a cross-tenant miss, so the response does not confirm the record exists. Then prove it with two accounts in two organizations. Opaque IDs raise the effort of guessing but are not the control.
What should a REST API rate limit return?
429 Too Many Requests, which RFC 6585 defines for a client that has sent too many requests in a given amount of time. Send Retry-After with it, which RFC 9110 section 10.2.3 allows to be an HTTP date or a number of seconds. Both read 19 September 2026. A request counter does not stop business-flow abuse, where every individual request is legitimate.
What should never be written to API logs?
Credentials of any kind: passwords, bearer tokens, refresh tokens, API keys, and session identifiers. Full card numbers belong out too, and so do whole request and response bodies unless you have a reason to keep them. OWASP adds two rules: sanitize log data against log injection, and write audit entries before and after security-relevant events. Redact at write time.





