Skip to main content

05 - API Security: OAuth 2.1, JWT Best Practices and Rate Limiting

BOLA, mass assignment, JWT, rate limiting and CORS demonstrated on an in-process test API, each with its fix and proof that the fix holds.

What do you think about this article?

05 - API Security: OAuth 2.1, JWT Best Practices and Rate Limiting
Sicurezza WebPhoto:Lisa from PexelsPexels

At a Glance

BOLA, mass assignment, JWT, rate limiting and CORS demonstrated on an in-process test API, each with its fix and proof that the fix holds.

A valid token does not tell you whose invoice it is

The most common flaw in APIs is not a forged token: it is a genuine token used to read what is not yours. The user Anna is authenticated, asks for /invoices/2, and the server hands her Bruno's invoice because it checks who you are but not whose the resource is. OWASP has ranked it first ever since the API-specific list has existed: API1:2023, Broken Object Level Authorization, which in CWE classification terms is CWE-639, "Authorization Bypass Through User-Controlled Key".

In this article each vulnerability is demonstrated against a small Express application written here for the purpose, and then fixed, with proof that the fix holds. No server is started and no port is opened: the requests go through the light-my-request test client, which calls the application function directly. The listings were run with Node v24.11.0, Express 5.3.0, jose 6.2.17, zod 4.6.7, cors 2.8.6 and light-my-request 6.8.0 (versions read on the npm registry on 2026-11-13), and the output shown under each block comes from two identical runs. The experiments follow the order in which you meet them in a review: authorization on the resource, on the properties, validity of the token, then rate limits and CORS.

BOLA: the owner filter belongs in the query

The first block contains two versions of the same endpoint. /v1 looks up the invoice by id and nothing else; /v2 puts ownerId === req.user.id inside the lookup condition. The authenticate middleware is a working assumption: it reads the identity from a test header, as if a check upstream had already verified it. The point of the experiment is that even with a certain identity /v1 is vulnerable.

import express from 'express';
import inject from 'light-my-request';

const invoices = [
  { id: 1, ownerId: 'anna', total: 120, iban: 'IT00-ANNA' },
  { id: 2, ownerId: 'bruno', total: 980, iban: 'IT00-BRUNO' },
];

// Test authentication: the identity comes from a header, as if a middleware had already verified it.
const authenticate = (req, res, next) => {
  req.user = { id: req.get('x-test-user') };
  req.user.id ? next() : res.status(401).json({ error: 'unauthenticated' });
};

const app = express();

// VULNERABLE: it checks who you are, not whose invoice it is
app.get('/v1/invoices/:id', authenticate, (req, res) => {
  const inv = invoices.find((i) => i.id === Number(req.params.id));
  return inv ? res.json(inv) : res.status(404).json({ error: 'not found' });
});

// FIXED: the owner is part of the filter; a stranger gets the same 404 as someone asking for a missing id
app.get('/v2/invoices/:id', authenticate, (req, res) => {
  const inv = invoices.find(
    (i) => i.id === Number(req.params.id) && i.ownerId === req.user.id,
  );
  return inv ? res.json(inv) : res.status(404).json({ error: 'not found' });
});

const call = async (url, user) => {
  const r = await inject(app, { method: 'GET', url, headers: { 'x-test-user': user } });
  console.log(`${user} GET ${url} -> ${r.statusCode} ${r.body}`);
};

await call('/v1/invoices/2', 'anna');   // anna reads bruno's invoice
await call('/v2/invoices/2', 'anna');   // same request, fixed version
await call('/v2/invoices/99', 'anna');  // missing id: identical response
await call('/v2/invoices/2', 'bruno');  // the owner reads their own
// Output:
// anna GET /v1/invoices/2 -> 200 {"id":2,"ownerId":"bruno","total":980,"iban":"IT00-BRUNO"}
// anna GET /v2/invoices/2 -> 404 {"error":"not found"}
// anna GET /v2/invoices/99 -> 404 {"error":"not found"}
// bruno GET /v2/invoices/2 -> 200 {"id":2,"ownerId":"bruno","total":980,"iban":"IT00-BRUNO"}

The first line of the output is the flaw: Anna receives Bruno's iban and amount with a 200. The second is the same request on the fixed version and gives 404. The third shows the design choice that matters: the missing id (99) and another user's id (2) produce exactly the same response. With a 403 for "it exists but it is not yours" Anna could enumerate the valid ids and work out how many invoices the system holds; the uniform 404 does not give that information away. The cost is that debugging becomes less talkative: in the logs it is worth telling the two cases apart, but not in the response. Where you really get hurt is a comparison placed after the read (read, then check): one oversight in a single handler out of a hundred and the flaw is back, which is why the owner filter should be built into the data access layer and not remembered by hand in every route.

BOPLA: whoever writes and whoever reads decides which fields exist

API3:2023 was born by merging two entries from 2019: excessive data exposure and mass assignment. OWASP itself says so, writing "previously named" for both in the description. The code below has both flaws in the same endpoint: Object.assign(db, req.body) copies into the record any key that arrives from the client (CWE-915), and the response returns the whole object, passwordHash included. The fixed version uses zod's z.strictObject, which rejects undeclared keys instead of silently ignoring them, and a publicView function that lists the output fields by hand.

import express from 'express';
import inject from 'light-my-request';
import { z } from 'zod';

const db = { id: 7, name: 'Anna', email: 'anna@example.test', role: 'user', passwordHash: 'x$9f2c...' };
const app = express();
app.use(express.json({ limit: '10kb' }));

// VULNERABLE: they copy everything that arrives and return everything they have
app.patch('/v1/me', (req, res) => { Object.assign(db, req.body); res.json(db); });

// FIXED: input schema with allowed keys, explicit projection on output
const UpdateMe = z.strictObject({
  name: z.string().min(2).max(100).optional(),
  email: z.email().optional(),
});
const publicView = ({ id, name, email }) => ({ id, name, email });

app.patch('/v2/me', (req, res) => {
  const parsed = UpdateMe.safeParse(req.body);
  if (!parsed.success) {
    return res.status(400).json({ error: 'validation', issues: parsed.error.issues.map((i) => i.code + ':' + i.keys?.join(',')) });
  }
  Object.assign(db, parsed.data);
  res.json(publicView(db));
});

const patch = async (url, body) => {
  const r = await inject(app, {
    method: 'PATCH', url, headers: { 'content-type': 'application/json' }, payload: JSON.stringify(body),
  });
  console.log(`PATCH ${url} ${JSON.stringify(body)}\n  -> ${r.statusCode} ${r.body}`);
};

await patch('/v1/me', { name: 'Anna R.', role: 'admin' });   // mass assignment succeeds, and the response also shows passwordHash
db.role = 'user';
await patch('/v2/me', { name: 'Anna R.', role: 'admin' });   // key not allowed: rejected
await patch('/v2/me', { name: 'Anna R.' });                  // allowed field: accepted
console.log('role in storage:', db.role);
// Output:
// PATCH /v1/me {"name":"Anna R.","role":"admin"}
//   -> 200 {"id":7,"name":"Anna R.","email":"anna@example.test","role":"admin","passwordHash":"x$9f2c..."}
// PATCH /v2/me {"name":"Anna R.","role":"admin"}
//   -> 400 {"error":"validation","issues":["unrecognized_keys:role"]}
// PATCH /v2/me {"name":"Anna R."}
//   -> 200 {"id":7,"name":"Anna R.","email":"anna@example.test"}
// role in storage: user

On /v1 the request {"name":"Anna R.","role":"admin"} promotes Anna to administrator and the response also prints the password hash: two harms in a single call. On /v2 the same request gets a 400 that names the suspicious key, and the last line confirms that the role in storage is still user. The detail not to overlook is strictObject: with a plain z.object unknown keys are discarded and the request goes through with a 200. From a protection standpoint that is fine (in the experiment with zod 4.6.7 the role field does not reach the validated data), but it hides the attempts, which in a real system are useful to log and count. The default behavior, that is discarding, is not the one that warns you: if you want a 400 you have to ask for it with strictObject.

JWT: the server chooses the algorithm, never the token

A signed JWT declares in its header which algorithm it was signed with. A naive verifier trusts that header, and two classic attacks follow. The first is alg: "none", a token with no signature. The second is algorithm confusion: the server verifies with an RSA public key, the attacker builds an HS256 token and uses that very public key as the HMAC secret, which by definition he knows. RFC 8725, the JWT Best Current Practices, asks in §3.1 that libraries let the caller fix the set of algorithms and use no others. The most cited real-world flaw of this family is CVE-2015-9235: according to the NVD entry it concerns the jsonwebtoken module before 4.2.2, where a token signed with a symmetric algorithm was accepted in place of an asymmetric one. It is therefore an algorithm confusion, not the none case, which the NVD entry does not refer to.

The test target has two verifiers. naiveVerify implements the naive logic by hand, with three branches that depend on the header. The second is jose's jwtVerify with three explicit constraints: algorithms: ['RS256'], issuer and audience. The malicious tokens are built in the listing itself, with local cryptography, and touch no service.

import crypto from 'node:crypto';
import { SignJWT, jwtVerify, generateKeyPair, exportSPKI } from 'jose';

const b64 = (o) => Buffer.from(typeof o === 'string' ? o : JSON.stringify(o)).toString('base64url');

// Naive verifier: it reads the algorithm from the token header and uses "the key" it has at hand.
function naiveVerify(token, key) {
  const [h, p, s] = token.split('.');
  const header = JSON.parse(Buffer.from(h, 'base64url'));
  if (header.alg === 'none') return JSON.parse(Buffer.from(p, 'base64url'));
  if (header.alg === 'HS256') {
    const mac = crypto.createHmac('sha256', key).update(`${h}.${p}`).digest('base64url');
    if (mac === s) return JSON.parse(Buffer.from(p, 'base64url'));
  }
  if (header.alg === 'RS256') {
    const ok = crypto.verify('sha256', Buffer.from(`${h}.${p}`), key, Buffer.from(s, 'base64url'));
    if (ok) return JSON.parse(Buffer.from(p, 'base64url'));
  }
  throw new Error('invalid signature');
}

const { publicKey, privateKey } = await generateKeyPair('RS256');
const publicPem = await exportSPKI(publicKey);
const ISS = 'https://auth.example.test', AUD = 'https://api.example.test';

// Legitimate token, issued by the authorization server with the private key
const good = await new SignJWT({ scope: 'invoices:read:own' })
  .setProtectedHeader({ alg: 'RS256' }).setSubject('anna').setIssuer(ISS).setAudience(AUD)
  .setExpirationTime('15m').sign(privateKey);

// Attack 1: alg "none", empty signature
const none = `${b64({ alg: 'none', typ: 'JWT' })}.${b64({ sub: 'anna', scope: 'admin' })}.`;
// Attack 2: algorithm confusion. The public PEM, which is public, used as the HMAC secret
const h2 = b64({ alg: 'HS256', typ: 'JWT' }), p2 = b64({ sub: 'anna', scope: 'admin' });
const confused = `${h2}.${p2}.` + crypto.createHmac('sha256', publicPem).update(`${h2}.${p2}`).digest('base64url');

const tryNaive = (label, t) => {
  try { const c = naiveVerify(t, publicPem); console.log('naive ', label, `-> ACCEPTED sub=${c.sub} scope=${c.scope}`); }
  catch { console.log('naive ', label, '-> rejected'); }
};
tryNaive('legitimate', good);
tryNaive('alg none  ', none);
tryNaive('confusion ', confused);

// Correct verification: algorithm chosen by the server, issuer and audience checked
const strict = (t) => jwtVerify(t, publicKey, { algorithms: ['RS256'], issuer: ISS, audience: AUD });
for (const [label, t] of [['legitimate', good], ['alg none', none], ['confusion', confused]]) {
  try { const { payload } = await strict(t); console.log('jose  ', label.padEnd(10), '-> accepted, sub =', payload.sub); }
  catch (e) { console.log('jose  ', label.padEnd(10), '-> rejected:', e.code); }
}

// Valid token but issued for another service (different audience)
const other = await new SignJWT({}).setProtectedHeader({ alg: 'RS256' }).setSubject('anna')
  .setIssuer(ISS).setAudience('https://other.example.test').setExpirationTime('15m').sign(privateKey);
try { await strict(other); } catch (e) { console.log('jose   other audience -> rejected:', e.code); }
// Output:
// naive  legitimate -> ACCEPTED sub=anna scope=invoices:read:own
// naive  alg none   -> ACCEPTED sub=anna scope=admin
// naive  confusion  -> ACCEPTED sub=anna scope=admin
// jose   legitimate -> accepted, sub = anna
// jose   alg none   -> rejected: ERR_JOSE_ALG_NOT_ALLOWED
// jose   confusion  -> rejected: ERR_JOSE_ALG_NOT_ALLOWED
// jose   other audience -> rejected: ERR_JWT_CLAIM_VALIDATION_FAILED

The first three lines are the picture that no functional test shows: the naive verifier accepts the legitimate token and also the two fakes, with the admin scope. jose rejects both with ERR_JOSE_ALG_NOT_ALLOWED because the header's algorithm is not in the list; the last line shows a genuine token, signed by the right key, rejected because it was issued for another audience. This is the case RFC 8725 §3.9 foresees: if the same issuer serves several applications, a token meant for one can be reused at another, and only the check on aud stops it. Likewise §3.8 requires verifying that keys belong to the issuer named in iss. Trying jwtVerify without the algorithms option (a check done separately), jose still rejects the none token and the HS256 token presented with an RSA key, because the key type does not match the algorithm; the explicit list remains useful because it makes the contract readable and prevents you from accepting algorithms you did not choose.

The weak symmetric key remains. The next block signs an HS256 token with the key mysecretkey123 and finds it again from a dictionary of six words, recomputing the HMAC offline. No request goes to any server, which is why rate limits are of no use against this attack.

import crypto from 'node:crypto';
import { SignJWT } from 'jose';

// The "target" is an HS256 token signed by us with a dictionary key
const token = await new SignJWT({ sub: 'anna', scope: 'invoices:read:own' })
  .setProtectedHeader({ alg: 'HS256' }).sign(new TextEncoder().encode('mysecretkey123'));

const [h, p, s] = token.split('.');
const wordlist = ['secret', 'password', 'changeme', 'jwtsecret', 'mysecretkey123', 'admin'];

// Offline attack: no request to the server, the HMAC is recomputed until it matches the signature
const found = wordlist.find(
  (w) => crypto.createHmac('sha256', w).update(`${h}.${p}`).digest('base64url') === s,
);
console.log('key found in the dictionary:', found);

// Countermeasure: 32 random bytes (256 bits), never written in the code
const strong = crypto.randomBytes(32);
const strongToken = await new SignJWT({ sub: 'anna' }).setProtectedHeader({ alg: 'HS256' }).sign(strong);
const [h2, p2, s2] = strongToken.split('.');
const hit = wordlist.find((w) => crypto.createHmac('sha256', w).update(`${h2}.${p2}`).digest('base64url') === s2);
console.log('random key found in the dictionary:', hit ?? 'no');
// Output:
// key found in the dictionary: mysecretkey123
// random key found in the dictionary: no

With a dictionary key the attack ends at the fifth attempt; with 32 random bytes the dictionary finds nothing, and that is exactly the condition RFC 8725 §3.5 sets: a memorable word must never be used directly as the key of a MAC such as HS256. In practice the key must be generated, not written, and kept out of the repository (secret management is covered in Secret management and credential rotation). For systems with several verifiers the asymmetric option is preferable, so that whoever verifies cannot sign.

  • Algorithm: a list fixed on the server, never the one in the header
  • Key: 256 random bits for HMAC, or an RSA or EC pair
  • iss and aud: both verified, otherwise a token from another service gets through
  • exp: short lifetime for the access token, because a signed JWT cannot revoke itself

The list summarizes four checks that can be forgotten independently of each other, and the last is the most expensive to fix afterwards: a self-contained token stays valid until exp even if the user is deactivated. That is why the lifetime should be kept in the order of minutes and revocation entrusted to the refresh token, which lives on the server and can be deleted.

OAuth 2.1 and scopes: what the access token authorizes

OAuth 2.1 is not yet a standard: the IETF Datatracker lists it as an Internet-Draft, draft-ietf-oauth-v2-1, revision 17 of 31 October 2026, expiring on 4 May 2027, and states that if approved it will replace RFC 6749 and RFC 6750. The list of differences from OAuth 2.0 is in §10 of the draft, "Differences from OAuth 2.0", and it is worth reading at the source because it consolidates decisions already taken in RFC 9700, the Best Current Practice on OAuth 2.0 security.

  • The authorization code flow requires the PKCE parameters (RFC 7636)
  • Redirect URIs are compared with exact string matching
  • The Implicit grant and the Resource Owner Password Credentials grant are omitted
  • The access token is not sent in the URI query string
  • Refresh tokens of public clients are sender-constrained or one-time use

These five items have no order of importance, but they share a common denominator: each closes a point where a short-lived secret could end up somewhere someone was reading it (browser history, the server log, a redirect to a look-alike domain). If your code still uses the password grant or accepts redirects with wildcards, the move to 2.1 is not "minor changes": it is the part to redo first.

What remains is applying the token to the resource. The next block implements requireScope. RFC 6750 §3.1 says that for insufficient_scope the server SHOULD respond 403 and MAY indicate the required scope, which is what the WWW-Authenticate header does. The same file also contains the CORS experiment, covered further below.

import express from 'express';
import cors from 'cors';
import inject from 'light-my-request';

const app = express();
const authenticate = (req, _res, next) => {
  req.user = { id: 'anna', scope: (req.get('x-test-scope') ?? '').split(' ').filter(Boolean) };
  next();
};

// Required scopes: RFC 6750 §3.1 prescribes 403 + WWW-Authenticate with error="insufficient_scope"
const requireScope = (...needed) => (req, res, next) => {
  if (needed.every((s) => req.user.scope.includes(s))) return next();
  res.set('WWW-Authenticate', `Bearer error="insufficient_scope", scope="${needed.join(' ')}"`);
  res.status(403).json({ error: 'insufficient_scope' });
};
app.get('/own', authenticate, requireScope('invoices:read:own'), (_req, res) => res.json({ ok: 'own' }));
app.get('/all', authenticate, requireScope('invoices:read:all'), (_req, res) => res.json({ ok: 'all' }));

for (const url of ['/own', '/all']) {
  const r = await inject(app, { url, headers: { 'x-test-scope': 'invoices:read:own' } });
  console.log(`token with invoices:read:own  GET ${url} -> ${r.statusCode} ${r.headers['www-authenticate'] ?? ''}`);
}

// CORS. VULNERABLE: it reflects any Origin and allows credentials
const reflect = express();
reflect.use(cors({ origin: true, credentials: true }));
reflect.get('/me', (_req, res) => res.json({ secret: "anna's data" }));

// FIXED: exact allowlist
const allowlist = new Set(['https://app.example.test']);
const strict = express();
strict.use(cors({ origin: (o, cb) => cb(null, !o || allowlist.has(o)), credentials: true }));
strict.get('/me', (_req, res) => res.json({ secret: "anna's data" }));

for (const [name, a] of [['reflecting', reflect], ['allowlist  ', strict]]) {
  for (const origin of ['https://app.example.test', 'https://evil.example.test']) {
    const r = await inject(a, { url: '/me', headers: { origin } });
    console.log(`${name} Origin=${origin.padEnd(26)} ACAO=${r.headers['access-control-allow-origin'] ?? '(absent)'} ACAC=${r.headers['access-control-allow-credentials'] ?? '(absent)'}`);
  }
}
// Output:
// token with invoices:read:own  GET /own -> 200 
// token with invoices:read:own  GET /all -> 403 Bearer error="insufficient_scope", scope="invoices:read:all"
// reflecting Origin=https://app.example.test   ACAO=https://app.example.test ACAC=true
// reflecting Origin=https://evil.example.test  ACAO=https://evil.example.test ACAC=true
// allowlist   Origin=https://app.example.test   ACAO=https://app.example.test ACAC=true
// allowlist   Origin=https://evil.example.test  ACAO=(absent) ACAC=(absent)

The first two lines concern scopes: the same token passes on /own and is turned away with a 403 on /all, with an indication of what was missing. The right scope does not replace the check on the resource seen with BOLA: invoices:read:own says "you may read invoices that are yours", but which ones are "yours" is still decided by the query. The scope limits the damage of a stolen token; it does not solve authorization.

Rate limiting: three algorithms, three behaviors at the boundaries

API4:2023 is called "Unrestricted Resource Consumption" and API6:2023 "Unrestricted Access to Sensitive Business Flows"; for countermeasures against repeated attempts the CWE references are 307 (excessive authentication attempts) and 770 (resources allocated without limits). The listing compares three algorithms with a simulated clock, so the experiment waits for nothing and gives the same result everywhere: a fixed window, a sliding window, a token bucket. The test is the most awkward burst: five requests at 9 seconds and five at 10, with a limit of five every ten seconds.

import express from 'express';
import inject from 'light-my-request';

// Injected clock: the test never sleeps and does not depend on the machine
let now = 0;
const clock = () => now;

// Fixed window: one counter per interval [k*W, (k+1)*W)
const fixedWindow = (limit, windowMs) => {
  const counts = new Map();
  return () => {
    const k = Math.floor(clock() / windowMs);
    const c = (counts.get(k) ?? 0) + 1;
    counts.set(k, c);
    return c <= limit;
  };
};

// Sliding window (log): counts the requests accepted in the last windowMs
const slidingWindow = (limit, windowMs) => {
  let log = [];
  return () => {
    log = log.filter((t) => t > clock() - windowMs);
    if (log.length >= limit) return false;
    log.push(clock());
    return true;
  };
};

// Token bucket: capacity tokens, refilled at refillPerSec tokens per second
const tokenBucket = (capacity, refillPerSec) => {
  let tokens = capacity, last = clock();
  return () => {
    tokens = Math.min(capacity, tokens + ((clock() - last) / 1000) * refillPerSec);
    last = clock();
    if (tokens < 1) return false;
    tokens -= 1;
    return true;
  };
};

// Burst of 5 requests just before the window boundary and 5 just after (limit: 5 per 10 s)
const burst = (allow) => {
  let ok = 0;
  for (const t of [9_000, 9_000, 9_000, 9_000, 9_000, 10_000, 10_000, 10_000, 10_000, 10_000]) {
    now = t; if (allow()) ok++;
  }
  return ok;
};
console.log('fixed window   accepted:', burst(fixedWindow(5, 10_000)), 'out of 10 in 1 s');
console.log('sliding window accepted:', burst(slidingWindow(5, 10_000)), 'out of 10 in 1 s');
console.log('token bucket   accepted:', burst(tokenBucket(5, 0.5)), 'out of 10 in 1 s');

// Express middleware: 429 with Retry-After (RFC 9110) and the fields of the httpapi-ratelimit-headers draft
const bucketPerKey = new Map();
const limiter = (req, res, next) => {
  const key = req.get('x-test-user') ?? req.ip;
  if (!bucketPerKey.has(key)) bucketPerKey.set(key, tokenBucket(3, 1)); // burst of 3, 1 per second
  if (bucketPerKey.get(key)()) return next();
  res.set('Retry-After', '1');
  res.set('RateLimit', '"default";r=0;t=1');
  res.status(429).json({ error: 'too_many_requests' });
};
const app = express();
app.post('/login', limiter, (req, res) => res.json({ ok: true }));

now = 0;
const hit = async (user) => (await inject(app, { method: 'POST', url: '/login', headers: { 'x-test-user': user } }));
const codes = [];
for (let i = 0; i < 5; i++) codes.push((await hit('anna')).statusCode);
console.log('anna, 5 attempts at t=0 s :', codes.join(' '));
const r = await hit('anna');
console.log('anna, 6th attempt         :', r.statusCode, 'Retry-After =', r.headers['retry-after'], 'RateLimit =', r.headers['ratelimit']);
console.log('bruno, first attempt      :', (await hit('bruno')).statusCode);
now = 1_000;
console.log('anna after 1 s            :', (await hit('anna')).statusCode);

// Second independent calculation: closed formula of the token bucket, accepted = capacity + rate * seconds
const formula = (capacity, rate, secs, rps) => Math.min(rps * secs, Math.floor(capacity + rate * secs));
console.log('formula capacity+rate*t (cap 3, 1/s, 10 s, 100 rps):', formula(3, 1, 10, 100));
now = 0;
const b = tokenBucket(3, 1); let acc = 0;
for (let ms = 0; ms <= 10_000; ms += 10) { now = ms; if (b()) acc++; }
console.log('simulation at 100 req/s for 10 s                  :', acc);
// Output:
// fixed window   accepted: 10 out of 10 in 1 s
// sliding window accepted: 5 out of 10 in 1 s
// token bucket   accepted: 5 out of 10 in 1 s
// anna, 5 attempts at t=0 s : 200 200 200 429 429
// anna, 6th attempt         : 429 Retry-After = 1 RateLimit = "default";r=0;t=1
// bruno, first attempt      : 200
// anna after 1 s            : 200
// formula capacity+rate*t (cap 3, 1/s, 10 s, 100 rps): 13
// simulation at 100 req/s for 10 s                  : 13

The fixed window lets all ten requests through, double the declared limit, because 9 s and 10 s fall into two different windows. The sliding window and the token bucket accept five. Then there is the middleware: Anna uses up the three burst tokens and from the fourth attempt receives 429 with Retry-After (RFC 9110 §10.2.3), Bruno has a bucket of his own and is unaffected, and one second later Anna is admitted again. The last check uses a second independent calculation: the closed formula "capacity + rate × time" gives 13 requests admitted in 10 seconds and the simulation at 100 requests per second admits 13.

The limits of this experiment are precise. The counters live in the memory of a single process: with several instances the real budget is the limit multiplied by the number of instances, and shared state is needed, a case covered in Multi-bucket rate limiting with Bucket4j. The key by IP does not stop whoever has many addresses, and the key by user does not protect a login endpoint, where the user is not yet known. As for the RateLimit header, it is defined by an Internet-Draft (draft-ietf-httpapi-ratelimit-headers, revision 11 of 23 May 2026) and not by an RFC: the form "default";r=0;t=1 is the one in its examples and may change before publication.

CORS: a browser rule, not a server defense

CORS decides whether the browser hands the response to the JavaScript of another origin; it does not stop curl from calling the API, and it does not replace authentication. It becomes a security flaw (CWE-942, "Permissive Cross-domain Security Policy with Untrusted Domains") when the server reflects any Origin received and at the same time enables credentials: any page can then read the data of the logged-in user. The second part of the previous listing's output shows exactly this.

With the cors package, origin: true means "reflect the request's origin": the origin evil.example.test receives Access-Control-Allow-Origin with its own name and Access-Control-Allow-Credentials: true. With the exact allowlist the same origin receives no CORS header, and the browser blocks the read. The value the package uses if you write nothing is * without credentials: fine for public resources, not for session data. The allowlist is compared with string equality, not with endsWith or unanchored regular expressions, because https://app.example.test.evil.example would satisfy them.

What this experiment does not cover

The five demonstrations touch API1, API2, API3, API4 and in part API6. The other entries of the 2023 list require other experiments: API5 (function level authorization), API7 (SSRF), API8 (security misconfiguration), API9 (improper inventory management) and API10 (unsafe consumption of APIs). Here they are only named, and for each of them the OWASP page contains scenarios and countermeasures. The previous article in the series, Secure authentication with sessions and cookies, covers the alternative to the token in browsers; SQL injection and input validation completes the part on validation, and the overall picture is in OWASP Top 10 2025. The next one, Supply chain security, shifts the focus from requests to dependencies.

What you can do now

You can recognize in a review three flaws that functional tests do not see: the resource read without an owner filter, the record updated with everything that arrives, the token verified with the algorithm it declares itself. You can write for each a test that fails before the fix and passes after, with no server and no network. You can choose a rate limit algorithm by its behavior at the boundaries rather than by its name, and you know what OAuth 2.1 says today, with the caveat that it is still a draft.

Frequently asked questions

What is BOLA and how do you fix it?

It is the vulnerability by which an authenticated user reads or modifies another user's resource by changing the identifier in the request. You fix it by putting the owner in the data access condition and answering the same way for a missing resource and someone else's resource.

Why should a JWT be verified with a fixed list of algorithms?

Because the token header declares the algorithm and is under the control of whoever presents it. If the server follows it, a token with alg none or signed in HMAC with the public key is accepted. RFC 8725 asks that the caller fix the allowed algorithms.

Is OAuth 2.1 a standard?

No, as of 2026-11-13 it is an IETF Internet-Draft, revision 17 of 31 October 2026. It gathers decisions already present in RFC 9700: PKCE in the authorization code flow, exact redirect URIs, no Implicit or password grant.

Is the fixed window wrong for rate limiting?

It is not wrong, but at window boundaries it lets through up to double the declared limit, as the experiment shows. For sensitive endpoints such as login, the sliding window or the token bucket is preferable.

The same series also includes XSS, CSRF and CSP.

Sources

The six listings were each run twice with identical output; they are test scripts, not code to paste into production. Not run: integration with a real authorization server, Redis and the network.

Commenti

Caricamento commenti...

Sign in to comment

Discuss this article in the community

Join the Telegram group to discuss with other developers, ask questions and share your experiences.

Did you like this article?

Explore other content on the blog or discover my projects