Authorization Testing
The highest-value security testing most teams are not doing — proving that the user who should not be able to do a thing genuinely cannot.
3 min read · updated 19 September 2026
Broken access control has topped the OWASP Top Ten for years. The reason is structural, and it is visible in almost every test suite:
Every test signs in as one user who is allowed to do everything.
So every test proves the permitted action works. Nothing proves the forbidden action fails, because nothing ever attempts it.
#The shape of the test
Two users. The same request. Two different expectations.
// The test that most suites are missing entirely.
describe('GET /orders/:id', () => {
let order: Order;
beforeEach(async () => {
order = await createOrder({ tenant: 'acme', owner: 'alice' });
});
it.each([
['the owner', () => authAs('alice', 'acme'), 200],
['a colleague', () => authAs('bob', 'acme'), 200],
['an admin', () => authAs('root', 'acme'), 200],
['another tenant', () => authAs('mallory', 'evil'), 404], // not 403: do not confirm it exists
['a revoked user', () => authAs('carol', 'acme', { revoked: true }), 401],
['nobody', () => ({}), 401]
])('as %s returns %i', async (_who, auth, expected) => {
await request(app).get(`/orders/${order.id}`).set(auth()).expect(expected);
});
});Six lines of table, six real security assertions, milliseconds to run. This is the cheapest security testing available and it is the kind most often absent.
#The matrix worth building
For each protected resource, enumerate the roles and the actions. The cells are your tests.
| read | update | delete | export | |
|---|---|---|---|---|
| owner | 200 | 200 | 200 | 200 |
| same-tenant member | 200 | 403 | 403 | 200 |
| same-tenant admin | 200 | 200 | 200 | 200 |
| other tenant | 404 | 404 | 404 | 404 |
| unauthenticated | 401 | 401 | 401 | 401 |
// C#, xUnit — the matrix as data.
public static TheoryData<string, string, HttpStatusCode> AccessMatrix => new()
{
{ "owner", "GET", HttpStatusCode.OK },
{ "member", "GET", HttpStatusCode.OK },
{ "member", "DELETE", HttpStatusCode.Forbidden },
{ "other-tenant","GET", HttpStatusCode.NotFound },
{ "anonymous", "GET", HttpStatusCode.Unauthorized }
};
[Theory]
[MemberData(nameof(AccessMatrix))]
public async Task Order_access_is_enforced(string who, string method, HttpStatusCode expected)
{
var client = factory.CreateClientAs(who);
var response = await client.SendAsync(new HttpRequestMessage(new HttpMethod(method), $"/orders/{_order.Id}"));
response.StatusCode.Should().Be(expected);
}#The bugs this finds
IDOR / horizontal escalation. The endpoint checks that you are signed in and forgets to check that the resource is yours. Sequential ids make it trivial to exploit; UUIDs make it harder to find and no less exploitable.
Missing tenant scoping in a query. The classic multi-tenant bug:
-- The bug: authenticated, but any tenant's order.
SELECT * FROM orders WHERE id = $1;
-- The fix, and what the test proves is present.
SELECT * FROM orders WHERE id = $1 AND tenant_id = $2;Vertical escalation. A member can call an admin endpoint because the route has an authentication filter and no authorization one.
Mass assignment. The update endpoint accepts a role or tenantId
field it should ignore:
it('ignores fields the caller is not allowed to set', async () => {
await request(app)
.patch(`/users/${alice.id}`)
.set(authAs('alice'))
.send({ displayName: 'Alice A', role: 'admin', tenantId: 'other' })
.expect(200);
const updated = await users.find(alice.id);
expect(updated.displayName).toBe('Alice A');
expect(updated.role).toBe('member'); // unchanged
expect(updated.tenantId).toBe('acme'); // unchanged
});Authorization in the UI only. The button is hidden and the endpoint is open. This is why these tests belong at the API level: hiding a control is not access control.
Leaks through side channels. The list endpoint is scoped correctly but the search endpoint is not; the resource is protected but its attachments are not; the API is scoped but an export job is not.
#403 versus 404
A deliberate decision, and one that silently regresses if untested.
404 when confirming existence is itself a leak. Another tenant's order, a private repository, a draft document. Returning 403 tells an attacker the id is real, which is exactly what they were probing for.
403 when the caller legitimately knows the resource exists and only the action is forbidden. A member viewing a document they may not delete.
Whichever you pick, assert on it. The distinction is invisible in code review and obvious in a test.
#Writing them so they get written
The reason these tests are missing is usually friction: the suite has one authenticated client and adding a second user is work. Remove the friction and the tests appear.
// One helper, and the barrier is gone.
export function authAs(user: string, tenant = 'acme', options: { revoked?: boolean } = {}) {
return { Authorization: `Bearer ${signTestToken({ sub: user, tenant, ...options })}` };
}// Playwright: two browser contexts, two signed-in users, in one test.
const aliceContext = await browser.newContext({ storageState: '.auth/alice.json' });
const malloryContext = await browser.newContext({ storageState: '.auth/mallory.json' });The screenplay pattern makes multi-actor tests natural at the UI level, which is its strongest single argument.
#A lint you can automate
Every route that returns or mutates user data should have an authorization test. That is checkable:
// A meta-test: fail the build when a protected route has no access test.
it('every protected route has an authorization test', () => {
const routes = listRoutes(app).filter((r) => r.requiresAuth);
const covered = new Set(readAuthorizationTestTargets());
const uncovered = routes.filter((r) => !covered.has(`${r.method} ${r.path}`));
expect(uncovered).toEqual([]);
});Crude, and it works: it converts "we should test authorization" from a good intention into a failing build.
#Where it fits
These are component or API tests. They are cheap there — two requests, two assertions — and expensive through a browser. Keep a handful of end-to-end tests for navigation and UI affordances, and put the matrix at the level where it costs nothing to be thorough.
Common questions
- Why is authorization so under-tested?
- Because the test suite signs in as one user who is allowed to do everything. Every test proves that the permitted action works; nothing proves the forbidden one fails, because no test ever attempts it.
- Should an unauthorized request return 403 or 404?
- 404 when confirming the resource exists would itself leak information — another tenant's order, a private repository. 403 when the resource is legitimately known to the caller and only the action is forbidden. Whichever you choose, test it, because the distinction is a deliberate decision that silently regresses.
- Where should authorization tests live?
- At the API or component level. They are cheap there — two requests and two assertions — and painfully slow through a browser. A few end-to-end tests covering navigation and UI affordances are worth having on top.
Runnable samples for this page
last test results ↗- TypeScript
typescript/src/quality/authorization-testing
Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.
Was this page useful?
Related topics
- Security TestingWhat automated security testing can and cannot find, the scans worth having in every pipeline, and why business-logic flaws remain a human problem.
- API TestingTesting HTTP and message interfaces directly — the level most teams under-invest in relative to its value, and how it replaces most slow end-to-end tests.
- Component TestingTesting one deployable in isolation through its real interface, with its own dependencies containerised and everything beyond its boundary stubbed.
- The Screenplay PatternActors, abilities, tasks and questions — a compositional alternative to page objects for suites with many user types and deep flows.
- Test Data ManagementWhere a test's data comes from decides whether the suite can run in parallel, twice in a row, or at all — builders, factories, fixtures and per-test isolation.
- Test StrategyDeciding what to test, at which level, and what not to test at all — written down, so it is a choice rather than an accident.