API Management Security: Gateway and Admin Controls

API management security has two halves. The first is what your API gateway enforces on each call: credentials, rate limits, request validation, TLS and logging. The second is the management plane that controls those policies: who can change them, where secrets live, which versions stay exposed, and what record a change leaves. Object-level authorization stays in the service. Test each policy change against the refusals it must keep.
The example on this page ran as printed in an empty folder with Node 26.10.0 and its built-in test runner, no packages. Every output block is copied from that run's log.
Qodex runs attack chains against your preview on every pull request, from flows it has already tested, with two real accounts. See Qodex security testing or try it on your API.
What API management security covers
An API management platform, such as Azure API Management, Amazon API Gateway or a self-hosted gateway, sits between callers and your services. It applies policies to traffic and gives you a place to publish, version and retire APIs. That makes it two things to secure:
The data plane. The policies applied to each request. A missing policy lets bad traffic through to the routes it was meant to protect.
The management plane. The admin roles, policy files, secrets, developer portal and version list. Whoever can change these can switch off the data plane's protection.
For where the gateway sits relative to service meshes and service-to-service traffic, see our API security architecture guide. This page covers how to configure and test the management layer.
What the gateway should enforce
Microsoft's guidance on mitigating the OWASP API Top 10 in Azure API Management names the policies for each job. Source: Mitigate OWASP API threats with Azure API Management, read 4 October 2026.
Authentication. Validate tokens at the edge with
validate-azure-ad-tokenfor Microsoft Entra orvalidate-jwtfor other issuers. Setrequire-expiration-timeandrequire-signed-tokensto true, and enforce the claims you need.Rate limits and quotas.
rate-limit-by-keyorrate-limitfor short windows, with stricter limits on sign-in, sign-up and password reset.quota-by-keyorquota-limitfor calls or bandwidth over longer periods.Validation.
validate-content,validate-parametersandvalidate-headersenforce the schema in your API specification, and themax-sizeattribute caps request and response size.Transport and CORS. No vulnerable protocols "(for example, TLS 1.0, 1.1)", and no wildcard
*in CORS policies.Policy inheritance. "Always inherit parent policies through the
<base>tag", so an API-level policy cannot quietly drop a global one.
Other platforms use other names for the same controls. Amazon API Gateway throttles with a token bucket, and AWS says throttles and quotas "are applied on a best-effort basis and should be thought of as targets rather than guaranteed request ceilings". Clients over the limit "may receive 429 Too Many Requests error responses". Source: Amazon API Gateway throttling, read 4 October 2026. If a hard limit matters, enforce it in the service too.
| Control | Gateway job | Service job | Management plane job | Test that proves it |
|---|---|---|---|---|
| Authentication | Reject missing or invalid credentials | Trust identity only from the gateway | Review token policy changes | No key gets 401 |
| Authorization | Coarse access per product or route | Check who owns each object | Review role and route changes | Another caller's object gets 404 |
| Traffic limits | Rate limit per key | Bound expensive work | Review limit changes | One call over the limit gets 429 |
| Validation | Reject bodies that break the schema | Check business rules | Review schema changes | A bad body gets 400 |
| Identity headers | Drop caller-sent identity headers | Refuse unsigned identity | Review forwarding config | A forged header changes nothing |
What the service must still check
A gateway sees a key and a URL. It does not know that order ord_a1 belongs to partner A. Microsoft says: "The best place to implement object level authorization is within the backend API itself." A custom gateway policy is its fallback for a backend that cannot be changed. Source: Mitigate OWASP API threats, read 4 October 2026.
The service also has to decide whether to trust what the gateway forwards. If it reads an identity header such as X-Consumer, anything that can reach the service directly can send that header. Either keep the service unreachable except through the gateway, or have the gateway sign the identity and the service verify it. The example below does the second.
Lock down the management plane
The admin side is where one change can disable every policy above. Microsoft's guidance for API Management covers access and change control. Source: Mitigate OWASP API threats, read 4 October 2026.
Least privilege. Use Azure Policy and role-based access control, and the guidance adds: "Grant minimum required privileges to every user."
Changes as code. Use "a DevOps process and infrastructure-as-code approach" outside development, so every policy change is a reviewed diff instead of a portal click.
No local admin accounts. The Azure security baseline for API Management says to disable or restrict local admin accounts "for only emergency use" and to use Microsoft Entra authentication instead. That baseline notes it is based on an older benchmark version and "may contain outdated guidance". Source: Azure security baseline for API Management, read 4 October 2026.
Changes as code also give you the place to run tests. A pull request that edits a policy runs the policy tests below before it can merge.
Protect secrets and the developer portal
Policies reference keys, certificates and backend credentials. Microsoft's rule: "Don't store secrets in policy files or in source control." Use named values that reference Azure Key Vault, or mark them as secret: "Never store secrets in plain-text named values." Use Key Vault for certificates too. Source: Mitigate OWASP API threats, read 4 October 2026.
The developer portal is where outside developers sign up and get keys, so it is part of your attack surface. The same guidance says to use Microsoft Entra ID or Entra External ID for portal sign-in. It adds: "Disable the default username and password authentication, which is less secure." It also says to assign user groups to products to control which APIs each group sees. A self-hosted portal needs its own update process; the managed one updates automatically.
Retire old versions and find shadow APIs
Gateways make it easy to keep old versions running, and old versions keep old weaknesses. OWASP's API9:2023 tells the story of a beta API host that ran the same password reset as production but without its rate limit. A researcher brute-forced the six-digit reset token there. Source: OWASP API9:2023, read 4 October 2026.
Microsoft's guidance gives the matching rules for API Management:
Commit to a maximum number of supported versions, "for example, 2 or 3 prior versions", and remove older ones.
"Ensure security controls are implemented across all available API versions."
Run separate API Management services for development, test and production.
Discover undocumented or unmanaged APIs and bring them under management.
OWASP's API8:2023, security misconfiguration, covers the rest of this drift: missing hardening, unnecessary methods, permissive CORS and error messages that leak stack traces. Source: OWASP API8:2023, read 4 October 2026. To build the list of what is deployed, see our guide to building an API inventory.
Keep logs and change records
You need two kinds of record. Request logs answer "what did this caller do", and the gateway can write them for every call. Change records answer "who changed this policy, when, and what did the diff say". With policies managed as code, the pull request is that record: author, reviewer, diff and the test run.
Log the decision, not the secret. Record the consumer, route, status and the policy that refused the call. Leave out keys, tokens and full request bodies. For what to watch in those logs, see our API security monitoring guide.
Test every policy change
The demo is a small gateway in front of a small service, both on your machine. The gateway's policy object is the part a management plane owns. The gateway checks API keys, rate-limits each key, caps and validates bodies, drops identity headers the caller sent and signs its own. The service accepts only signed identities, checks who owns each order, and sets the owner itself so a request body cannot. If the service is down, the gateway answers 502. Save this as gateway.mjs:
// gateway.mjs: a small API gateway and the service behind it. Node built-ins only.
import { createServer, request } from 'node:http';
import { randomBytes, createHmac, timingSafeEqual } from 'node:crypto';
// The policy the management plane owns. A change here is a security change.
export const policy = {
routes: [
{ method: 'GET', path: '/v1/orders/:id', auth: 'key' },
{ method: 'POST', path: '/v1/orders', auth: 'key', body: { sku: 'string', qty: 'number' } },
],
};
const keys = new Map(); // api key -> { consumer, limit, windowStart, count }
const signingKey = randomBytes(32); // per process, shared by gateway and service only
const sign = (value) => createHmac('sha256', signingKey).update(value).digest('hex');
const orders = new Map([['ord_a1', { id: 'ord_a1', owner: 'partner-a', sku: 'A-100', qty: 1 }]]);
function send(res, status, body, headers = {}) {
res.writeHead(status, { 'content-type': 'application/json', ...headers });
res.end(JSON.stringify(body));
}
const MAX_BODY = 10_000; // bytes
const TOO_LARGE = Symbol('too large');
const CUT_OFF = Symbol('cut off');
async function readBody(req) {
let raw = '', size = 0;
try {
for await (const chunk of req) { size += chunk.length; if (size <= MAX_BODY) raw += chunk; }
} catch {
return CUT_OFF; // the client stopped sending mid-body
}
if (size > MAX_BODY) return TOO_LARGE;
return raw;
}
function validBody(raw, shape) {
let body;
try { body = JSON.parse(raw); } catch { return false; }
if (!body || Object.keys(body).sort().join() !== Object.keys(shape).sort().join()) return false;
if (!Object.entries(shape).every(([k, type]) => typeof body[k] === type)) return false;
return Number.isInteger(body.qty) && body.qty > 0 && body.qty <= 100;
}
// The service trusts the consumer header only when the gateway signed it,
// and it still checks who owns each order.
function startService() {
const service = createServer(async (req, res) => {
const consumer = String(req.headers['x-consumer'] ?? '');
const given = Buffer.from(String(req.headers['x-consumer-signature'] ?? ''), 'hex');
const expected = Buffer.from(sign(consumer), 'hex');
if (!consumer || given.length !== expected.length || !timingSafeEqual(given, expected)) {
return send(res, 401, { error: 'unsigned request' });
}
const match = /^\/v1\/orders\/(\w+)$/.exec(req.url);
if (req.method === 'GET' && match) {
const order = orders.get(match[1]);
if (!order || order.owner !== consumer) return send(res, 404, { error: 'not found' });
return send(res, 200, order);
}
if (req.method === 'POST' && req.url === '/v1/orders') {
const id = `ord_${randomBytes(4).toString('hex')}`;
const raw = await readBody(req);
if (raw === TOO_LARGE) return send(res, 413, { error: 'body too large' });
if (raw === CUT_OFF) return send(res, 400, { error: 'incomplete body' });
orders.set(id, { ...JSON.parse(raw), id, owner: consumer }); // the body cannot set id or owner
return send(res, 201, orders.get(id));
}
send(res, 404, { error: 'not found' });
});
return new Promise((resolve) => service.listen(0, '127.0.0.1', () => resolve(service)));
}
function startGateway(servicePort) {
const gateway = createServer(async (req, res) => {
const path = new URL(req.url, 'http://localhost').pathname;
const route = policy.routes.find((r) => r.method === req.method
&& new RegExp('^' + r.path.replace(':id', '\\w+') + '$').test(path));
if (!route) return send(res, 404, { error: 'not a published route' });
let consumer = 'anonymous';
if (route.auth === 'key') {
const key = keys.get(String(req.headers['x-api-key'] ?? ''));
if (!key) return send(res, 401, { error: 'missing or unknown api key' });
if (Date.now() - key.windowStart >= 60_000) { key.windowStart = Date.now(); key.count = 0; }
if (++key.count > key.limit) return send(res, 429, { error: 'rate limit' }, { 'retry-after': '60' });
consumer = key.consumer;
}
const raw = await readBody(req);
if (raw === TOO_LARGE) return send(res, 413, { error: 'body too large' });
if (raw === CUT_OFF) return send(res, 400, { error: 'incomplete body' });
if (route.body && !validBody(raw, route.body)) return send(res, 400, { error: 'invalid body' });
// Drop identity headers the caller sent, then set our own, signed.
const headers = { 'content-type': 'application/json', 'content-length': Buffer.byteLength(raw) };
headers['x-consumer'] = consumer;
headers['x-consumer-signature'] = sign(consumer);
const upstream = request({ host: '127.0.0.1', port: servicePort, path: req.url, method: req.method, headers }, (up) => {
res.writeHead(up.statusCode, { 'content-type': 'application/json' });
up.pipe(res);
});
upstream.on('error', () => {
if (!res.headersSent) send(res, 502, { error: 'service unavailable' });
else res.destroy();
});
upstream.end(raw);
});
return new Promise((resolve) => gateway.listen(0, '127.0.0.1', () => resolve(gateway)));
}
export async function start() {
const service = await startService();
const gateway = await startGateway(service.address().port);
const issueKey = (consumer, limit = 100) => {
const key = randomBytes(24).toString('base64url');
keys.set(key, { consumer, limit, windowStart: Date.now(), count: 0 });
return key;
};
return { gateway, service, issueKey };
}
The demo serves plain HTTP on 127.0.0.1 only. In production, the hops from client to gateway and from gateway to service both need TLS. The tests below are the refusals every policy change has to keep. Save this as policy.test.mjs:
// policy.test.mjs: checks every policy change has to keep passing.
import { test, before, after } from 'node:test';
import assert from 'node:assert/strict';
import { connect } from 'node:net';
import { start, policy } from './gateway.mjs';
// Sends half a request body, then hangs up, the way a dropped mobile connection does.
const cutOff = (port, path, header = '') => new Promise((resolve) => {
const socket = connect(port, '127.0.0.1', () => {
socket.write(`POST ${path} HTTP/1.1\r\nHost: 127.0.0.1\r\nContent-Type: application/json\r\n`
+ `Content-Length: 100\r\n${header}\r\n{"half":`);
setTimeout(() => { socket.destroy(); setTimeout(resolve, 100); }, 50);
});
});
let gateway, service, issueKey, gw, svc;
before(async () => {
({ gateway, service, issueKey } = await start());
gw = `http://127.0.0.1:${gateway.address().port}`;
svc = `http://127.0.0.1:${service.address().port}`;
});
after(() => { gateway.close(); service.close(); });
const order = { sku: 'A-100', qty: 2 };
const call = (path, { key, method = 'GET', body, headers = {} } = {}) => fetch(gw + path, {
method,
headers: { 'content-type': 'application/json', ...(key ? { 'x-api-key': key } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body),
});
test('every published route asks for a key', async () => {
for (const route of policy.routes) {
const path = route.path.replace(':id', 'ord_a1');
const res = await call(path, { method: route.method, body: route.body ? order : undefined });
assert.equal(res.status, 401, `${route.method} ${route.path}`);
}
});
test('a route nobody published is not forwarded', async () => {
assert.equal((await call('/v1/admin/export', { key: issueKey('partner-a') })).status, 404);
});
test('a bad body stops at the gateway', async () => {
const key = issueKey('partner-a');
assert.equal((await call('/v1/orders', { key, method: 'POST', body: { sku: 'A-100', qty: -5 } })).status, 400);
assert.equal((await call('/v1/orders', { key, method: 'POST', body: { ...order, price: 0 } })).status, 400);
assert.equal((await call('/v1/orders', { key, method: 'POST', body: { sku: 'x'.repeat(20_000), qty: 1 } })).status, 413);
assert.equal((await call('/v1/orders', { key, method: 'POST', body: order })).status, 201);
});
test('each key has its own rate limit', async () => {
const key = issueKey('partner-a', 3);
const codes = [];
for (let i = 0; i < 4; i++) codes.push((await call('/v1/orders/ord_a1', { key })).status);
assert.deepEqual(codes, [200, 200, 200, 429]);
});
test('the service still checks who owns the order', async () => {
assert.equal((await call('/v1/orders/ord_a1', { key: issueKey('partner-a') })).status, 200);
assert.equal((await call('/v1/orders/ord_a1', { key: issueKey('partner-b') })).status, 404);
});
test('a caller cannot choose its own identity', async () => {
const res = await call('/v1/orders/ord_a1', {
key: issueKey('partner-b'),
headers: { 'x-consumer': 'partner-a' },
});
assert.equal(res.status, 404);
});
test('the service refuses a request that skipped the gateway', async () => {
const res = await fetch(`${svc}/v1/orders/ord_a1`, { headers: { 'x-consumer': 'partner-a' } });
assert.equal(res.status, 401);
});
test('a body cut off mid-stream does not take the gateway down', async () => {
await cutOff(gateway.address().port, '/v1/orders', `x-api-key: ${issueKey('partner-a')}\r\n`);
assert.equal((await call('/v1/orders/ord_a1', { key: issueKey('partner-a') })).status, 200);
});
test('the gateway answers 502 when the service is down', async () => {
const second = await start();
await new Promise((resolve) => second.service.close(resolve));
const res = await fetch(`http://127.0.0.1:${second.gateway.address().port}/v1/orders/ord_a1`, {
headers: { 'x-api-key': second.issueKey('partner-a') },
});
assert.equal(res.status, 502);
second.gateway.close();
});
Run it:
$ node --test
✔ every published route asks for a key (14.594375ms)
✔ a route nobody published is not forwarded (0.758666ms)
✔ a bad body stops at the gateway (5.639916ms)
✔ each key has its own rate limit (2.812125ms)
✔ the service still checks who owns the order (1.373667ms)
✔ a caller cannot choose its own identity (0.678708ms)
✔ the service refuses a request that skipped the gateway (0.672833ms)
✔ a body cut off mid-stream does not take the gateway down (152.788917ms)
✔ the gateway answers 502 when the service is down (1.477333ms)
ℹ tests 9
ℹ suites 0
ℹ pass 9
ℹ fail 0
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 277.996542
Now make the kind of change that happens under deadline. Someone publishes a reports route for a partner demo and skips the key. Add this line to policy.routes in gateway.mjs:
{ method: 'GET', path: '/v1/reports', auth: 'none' },
Run the tests again:
$ node --test
✖ every published route asks for a key (17.715875ms)
✔ a route nobody published is not forwarded (1.048625ms)
✔ a bad body stops at the gateway (4.452458ms)
✔ each key has its own rate limit (2.343209ms)
✔ the service still checks who owns the order (0.977583ms)
✔ a caller cannot choose its own identity (0.986292ms)
✔ the service refuses a request that skipped the gateway (1.165625ms)
✔ a body cut off mid-stream does not take the gateway down (153.739917ms)
✔ the gateway answers 502 when the service is down (1.603333ms)
ℹ tests 9
ℹ suites 0
ℹ pass 8
ℹ fail 1
ℹ cancelled 0
ℹ skipped 0
ℹ todo 0
ℹ duration_ms 282.656
✖ failing tests:
test at policy.test.mjs:31:1
✖ every published route asks for a key (17.715875ms)
AssertionError [ERR_ASSERTION]: GET /v1/reports
404 !== 401
...
The first test walks every published route without a key and expects 401. The new route answered 404 instead: the gateway let the anonymous call through to the service. We trimmed the stack trace from that output. Delete the line, or give the route auth: 'key', and the suite passes again.
Run these tests in the same pipeline that deploys the policy. On a managed platform the shape is the same: a deploy job applies the policy to a test instance, and the tests run against that instance before production.
Conclusion
Secure the gateway's policies and the people and process that change them, and keep ownership checks in the service. Then turn the refusals you care about into tests. 401 without a key, 429 over the limit, 400 for a bad body, 413 for an oversized one, 404 for another caller's object, nothing gained from a forged header. Run them on every policy change. For the broader practice list, see our API security best practices, and for the wider test plan, the API security testing guide.
Frequently Asked Questions
What is API management security?
It is the security of the layer that manages your APIs: the policies the gateway applies to each call, and the management plane that controls them. That plane covers admin roles, secrets, the developer portal, versions and change records.
How is API management security different from API security?
API security covers the API end to end, including the code in each service. API management security is the part handled by the management layer. It cannot replace checks that need business knowledge, such as who owns an object.
Does gateway authentication prevent broken object level authorization?
No. The gateway knows which key called, not which records that caller owns. Microsoft's guidance puts object-level authorization in the backend API. The demo's ownership test shows partner B refused partner A's order by the service, after the gateway accepted B's key.
What is the difference between a rate limit and a quota?
In Azure API Management, rate limits such as rate-limit-by-key cap calls over short windows to absorb spikes. Quotas such as quota-by-key cap calls or bandwidth over longer periods. AWS treats both as best-effort targets, not guaranteed ceilings.
Does signing in to the developer portal secure the API?
No. Portal sign-in controls who can read documentation and get keys. Each API call is still checked by the gateway's policies and the service. Secure the portal too, with single sign-on and the default username and password sign-in disabled.
How should policy changes be reviewed?
Manage policies as code, review each change as a pull request, and run tests that assert the refusals the policy must keep. Restrict who can change policies directly, and keep local admin accounts for emergencies only.
Why do old API versions create risk?
Unsupported versions can miss later security fixes and controls. OWASP's API9 example is a beta host without the production rate limit. Limit the number of supported versions, apply the same controls to all of them, and remove the rest.





