Role-Based Access Control Testing in Playwright
A test suite that only ever logs in as an admin will happily ship a bug where a read-only viewer can delete records, because no test ever tried. Authorization is where real security incidents hide, yet it is the layer most end-to-end suites skip. In this guide you will learn practical Playwright RBAC role-based access testing in TypeScript: how to capture a separate authenticated session per role, assert what each role can and cannot see, verify that the API rejects forbidden actions, and keep the whole thing fast with project dependencies and storage state.
🎠Want to master this with real projects? Join the Playwright Automation Mastery course at The Testing Academy.
Contents
What RBAC testing actually has to prove
Role-Based Access Control assigns permissions to roles, and roles to users. A typical SaaS app has at least an admin, an editor or member, and a viewer. Good authorization testing has to prove two opposite things for every role: that allowed actions work, and that forbidden actions are blocked. The second half is the one teams forget. It is not enough to hide a “Delete” button in the UI for a viewer; the API behind that button must also return a 403. Otherwise an attacker who crafts the request directly walks right through.
So an RBAC test plan covers three surfaces. The navigation surface (which routes a role can reach), the UI surface (which controls a role can see and use), and the API surface (which requests the backend authorizes). Playwright is unusually good here because a single test can drive the browser and fire raw API requests through the same authenticated context, so you check the button and the endpoint behind it in one place.
Authenticate once per role with storage state
The slowest, flakiest way to test multiple roles is to log in through the UI at the start of every test. The fast, stable way is to log in once per role in setup, save the cookies and local storage to disk with storageState, and have every test reuse the saved session. Playwright’s recommended pattern is an authentication setup project that other projects depend on.
Start by writing one setup file that signs in as each role and persists its state. Using test.describe.configure({ mode: 'parallel' }) lets the role logins run concurrently.
// tests/auth.setup.ts
import { test as setup, expect } from '@playwright/test';
import fs from 'node:fs';
const authDir = '.auth';
fs.mkdirSync(authDir, { recursive: true });
type Role = 'admin' | 'editor' | 'viewer';
const credentials: Record<Role, { email: string; password: string }> = {
admin: { email: 'admin@example.com', password: process.env.ADMIN_PW! },
editor: { email: 'editor@example.com', password: process.env.EDITOR_PW! },
viewer: { email: 'viewer@example.com', password: process.env.VIEWER_PW! },
};
setup.describe.configure({ mode: 'parallel' });
for (const role of Object.keys(credentials) as Role[]) {
setup(`authenticate as ${role}`, async ({ page }) => {
const { email, password } = credentials[role];
await page.goto('/login');
await page.getByLabel('Email').fill(email);
await page.getByLabel('Password').fill(password);
await page.getByRole('button', { name: 'Sign in' }).click();
// Wait for a real signal that auth succeeded before saving state.
await expect(page.getByRole('heading', { name: 'Dashboard' })).toBeVisible();
await page.context().storageState({ path: `${authDir}/${role}.json` });
});
}
Now wire those saved states into playwright.config.ts. One project runs the setup; each role’s test project depends on it and points storageState at the matching file. The testMatch filter keeps role-specific specs in their own naming scheme.
// playwright.config.ts
import { defineConfig, devices } from '@playwright/test';
export default defineConfig({
use: { baseURL: 'https://app.example.com', trace: 'on-first-retry' },
projects: [
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
{
name: 'admin',
testMatch: /.*\.admin\.spec\.ts/,
use: { ...devices['Desktop Chrome'], storageState: '.auth/admin.json' },
dependencies: ['setup'],
},
{
name: 'editor',
testMatch: /.*\.editor\.spec\.ts/,
use: { ...devices['Desktop Chrome'], storageState: '.auth/editor.json' },
dependencies: ['setup'],
},
{
name: 'viewer',
testMatch: /.*\.viewer\.spec\.ts/,
use: { ...devices['Desktop Chrome'], storageState: '.auth/viewer.json' },
dependencies: ['setup'],
},
],
});
Add .auth/ to your .gitignore. These files contain live session tokens and must never be committed.
A role-aware fixture for any-role tests
Splitting tests into .admin.spec.ts and .viewer.spec.ts is clean, but sometimes you want a single test body to run against several roles, or to grab a “viewer context” inside an admin test to confirm cross-role behavior. A custom fixture that hands back a browser context for a named role is the most flexible tool. It uses browser.newContext({ storageState }) so each call is isolated.
// tests/fixtures.ts
import { test as base, type Page, type BrowserContext } from '@playwright/test';
type Role = 'admin' | 'editor' | 'viewer';
type RbacFixtures = {
pageForRole: (role: Role) => Promise<Page>;
};
export const test = base.extend<RbacFixtures>({
pageForRole: async ({ browser }, use) => {
const contexts: BrowserContext[] = [];
const factory = async (role: Role) => {
const context = await browser.newContext({
storageState: `.auth/${role}.json`,
});
contexts.push(context);
return context.newPage();
};
await use(factory);
// Teardown: close every context this test opened.
for (const context of contexts) await context.close();
},
});
export { expect } from '@playwright/test';
With that fixture you can parametrize a positive-access test across roles. Data-driven loops keep the assertions in one place and produce one reported test per role.
// tests/dashboard.spec.ts
import { test, expect } from './fixtures';
const rolesThatSeeBilling = ['admin'] as const;
const rolesThatDoNot = ['editor', 'viewer'] as const;
for (const role of rolesThatSeeBilling) {
test(`${role} can open the Billing page`, async ({ pageForRole }) => {
const page = await pageForRole(role);
await page.goto('/billing');
await expect(page.getByRole('heading', { name: 'Billing' })).toBeVisible();
await expect(page.getByRole('button', { name: 'Update card' })).toBeEnabled();
});
}
for (const role of rolesThatDoNot) {
test(`${role} is denied the Billing page`, async ({ pageForRole }) => {
const page = await pageForRole(role);
await page.goto('/billing');
// The app should redirect or show a 403 view, not the billing form.
await expect(page.getByText('You do not have access')).toBeVisible();
await expect(page.getByRole('button', { name: 'Update card' })).toBeHidden();
});
}
Testing negative access: the part teams skip
Hiding a control in the UI is not authorization, it is decoration. The authoritative check lives at the API. Playwright lets you reuse a role’s authenticated cookies to send a raw request through page.request (or a standalone request context), so you can confirm the backend itself returns 403 for a forbidden action. This is the single most valuable RBAC test you can write.
// tests/api-authorization.spec.ts
import { test, expect } from '@playwright/test';
// Each project injects its own storageState, so page.request
// carries that role's session cookies automatically.
test.describe('viewer is blocked at the API', () => {
test('cannot delete a project', async ({ page }) => {
const res = await page.request.delete('/api/projects/42');
expect(res.status()).toBe(403);
const body = await res.json();
expect(body.error).toMatch(/forbidden|not allowed/i);
});
test('cannot create a user via API', async ({ page }) => {
const res = await page.request.post('/api/users', {
data: { email: 'mole@example.com', role: 'admin' },
});
expect(res.status()).toBe(403);
});
});
test.describe('admin is authorized at the API', () => {
test('can delete a project', async ({ page }) => {
const res = await page.request.delete('/api/projects/99');
expect(res.ok()).toBeTruthy(); // 2xx
});
});
If you run this spec under both the viewer and admin projects, the page.request calls automatically pick up each project’s storageState cookies. The viewer’s DELETE must be rejected; the admin’s must succeed. When a test like the viewer DELETE suddenly returns 200, you have caught a broken-access-control regression before it shipped, which is exactly the class of bug that tops the OWASP risk list.
Asserting multiple permissions in one pass
A single role often has a long permission matrix. Failing on the first wrong control means you re-run the suite over and over to discover the next mismatch. Soft assertions with expect.soft let one test report every violation in a single run, which is ideal for an RBAC matrix. The test still fails if any soft assertion fails, but it gathers all the evidence first.
// tests/viewer-matrix.viewer.spec.ts
import { test, expect } from '@playwright/test';
test('viewer permission matrix on the project page', async ({ page }) => {
await page.goto('/projects/42');
// Allowed: read-only controls are present.
await expect.soft(page.getByRole('heading', { name: 'Project 42' })).toBeVisible();
await expect.soft(page.getByRole('tab', { name: 'Activity' })).toBeVisible();
// Forbidden: every mutating control must be absent or disabled.
await expect.soft(page.getByRole('button', { name: 'Edit' })).toBeHidden();
await expect.soft(page.getByRole('button', { name: 'Delete' })).toBeHidden();
await expect.soft(page.getByRole('button', { name: 'Invite member' })).toBeHidden();
// Fail the test explicitly if any soft check failed.
expect(test.info().errors).toHaveLength(0);
});
🚀 Level Up Your Playwright
From locators to CI pipelines — build a production-grade Playwright + TypeScript framework step by step.
Where to assert: UI vs API surfaces
Not every check belongs at every layer. Use the table below to decide where each kind of assertion gives you the most signal for the least flake.
| Check | Best surface | Playwright tool | Why |
|---|---|---|---|
| Route is blocked for a role | UI | page.goto + redirect/403 assertion | Confirms router guards and real navigation |
| Mutating control hidden | UI | toBeHidden / toBeDisabled | Catches accidental exposure of dangerous actions |
| Forbidden action rejected | API | page.request + status 403 | The authoritative authorization boundary |
| Allowed action succeeds | UI + API | click flow + 2xx response | Proves the role can actually do its job |
| Full permission matrix | UI | expect.soft | Reports every violation in one run |
| Token tampering / role swap | API | standalone request context | Simulates a crafted request bypassing the UI |
Practical tips that keep RBAC suites stable
- Seed roles deterministically. Create the admin, editor, and viewer accounts via an API or database seed in global setup so the same permissions exist on every run, instead of relying on hand-made test users.
- Refresh storage state when sessions expire. If tokens are short-lived, re-run the setup project (it is fast) rather than chasing mysterious mid-suite logouts. You can also gate it on file age.
- Assert the negative, not just the positive. For every “admin can X” test, write the matching “viewer cannot X” test, and verify it at the API, not only the button.
- Never trust a hidden button alone. A hidden control plus an authorized endpoint is still a vulnerability.
- Tag and shard. Use a
@rbacgrep tag so security-critical specs can run on every pull request even when the full suite runs nightly.
Put together, these patterns turn Playwright RBAC role-based access testing into a repeatable, layered safety net: log in once per role with storageState, assert allowed and forbidden behavior across both the UI and the API, collect whole permission matrices with expect.soft, and seed roles deterministically so the results are trustworthy. The negative tests are the ones that earn their keep, because a broken authorization boundary is a security incident, not just a failing assertion.
FAQ
Should I log in through the UI in every RBAC test?
No. Logging in through the UI on every test is slow and a common source of flake. Instead, authenticate once per role in a setup project, save each session with page.context().storageState({ path }), and have each test project load the matching file via the storageState option. Tests then start already authenticated, which is faster and far more stable. Only your dedicated login spec should drive the actual sign-in form.
Because hiding a button is not authorization, it is presentation. An attacker can send the underlying request directly with a lower-privileged session and bypass the UI entirely. The real authorization boundary lives at the backend, so you must confirm that a forbidden action returns 403 at the API level. In Playwright, reuse the role’s cookies through page.request or a standalone request context and assert the status code. This catches broken-access-control bugs the UI checks never will.
How do I run the same test body against multiple roles?
Two approaches work well. You can create one Playwright project per role in playwright.config.ts, each pointing storageState at a different saved session, and let testMatch route specs to roles. Or you can write a custom fixture that calls browser.newContext({ storageState }) for a named role and returns a fresh page, then loop over an array of roles with a data-driven for loop so each role becomes its own reported test. Use projects for full role suites and the fixture when one test needs several roles at once.
🎓 Master Playwright End to End
Join hundreds of SDETs building real automation frameworks. Lifetime access, hands-on projects, and a job-ready portfolio.
