Skip to content
End To End Tester

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.

typescript
// 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
csharp
// 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:

sql
-- 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:

typescript
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.

typescript
// 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 })}` };
}
typescript
// 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:

typescript
// 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 ↗
  • TypeScripttypescript/src/quality/authorization-testing

Working tests, not fragments — they run in CI on every push to 8exgh/endtoendtester-samples.

Was this page useful?