← All posts
Best practices

Hide the Edit button. Test the permission.

The permission check in four steps

  1. Hiding Edit does not prove that the server rejects the request.
  2. Check the identity, requested action, and record ownership at the server boundary.
  3. Prove each identity can read its own synthetic fixture before checking the denial.
  4. Pair allowed and denied cases, then inspect the response and the record's final state.

Picture a small regression worth rehearsing. Alice is a reader in tenant A. You hide the Edit button, the UI test goes green, and the screenshot looks ready for the release notes.

Then Alice pastes the known URL of Bob's test invoice from tenant B. Under this example policy, the server must refuse it without returning Bob's private invoice data.

The button has done its job. CSS does not get a vote on the API request.

A login test proves that an identity can establish a session. The permission check needs three things: who is asking, what action they want, and whose record it is. This guide turns that rule into a small suite: two controlled identities, paired allow and deny cases, and an assertion on the server's answer.

Start with an identity, an action, and a record

Do not start with “the admin page is protected.” Write the policy you expect, including ownership. A role name alone does not tell you which customer's records a user may access.

Here is a small example policy for an invoice application. These are roles in the application under test, not TestVibe workspace roles.

IdentityRecordActionExpected result
Alice, reader in tenant ATenant A invoiceReadAllow
Alice, reader in tenant ATenant B invoiceReadDeny
Bob, reader in tenant BTenant B invoiceReadAllow
Alice, reader in tenant ATenant A invoiceEditDeny
Eli, editor in tenant ATenant A invoiceEditAllow
Eli, editor in tenant ATenant B invoiceReadDeny

The first three rows test a record-ownership boundary. The next two test a role boundary. The last makes sure broader permissions inside one tenant do not accidentally become broader permissions everywhere.

OWASP's authorization testing guidance models permissions using roles, services and, where needed, business data. Your table can be smaller than its automation examples. The important part is that the expected decision is reviewable before the test is written.

Give a denial a positive control

“Bob's invoice did not open” is a weak result. Maybe the invoice was deleted. Maybe the URL is wrong. Maybe Alice never signed in. An outage can ace a deny-only suite. It should not get a promotion.

Seed two synthetic invoices in an environment you are allowed to test, with dedicated accounts that own them. Give each invoice a distinctive, non-sensitive reference. Confirm Alice can read hers and Bob can read his before checking that Alice cannot read Bob's.

Keep these records independent of other suites. A cleanup job that deletes Bob's fixture halfway through the run changes the question from authorization to missing data. The test-data guide covers isolation and reset strategies; a known foreign fixture is not a reason to probe arbitrary customer identifiers.

For write checks, pair a denied reader update with an allowed editor update on disposable data. Verify the record's final value through an authorized identity. A red toast does not prove that a rejected request left the record unchanged.

Keep the UI assertion: a reader should not be offered an edit action. Then test a known, approved deep link to the protected screen. A user should not gain access simply by arriving through a different navigation path.

In TestVibe, start with a browser journey whose setup and expected outcomes are explicit. This illustrative feature assumes the seeded paths and visible messages shown below exist in your application:

Feature: Invoice ownership boundaries

  Scenario: Alice cannot open Bob's seeded invoice
    Given I open the application's sign-in page
    And I sign in with "{{secret:ALICE_EMAIL}}" and "{{secret:ALICE_PASSWORD}}"
    And the account menu identifies "Alice test reader"
    When I open the seeded invoice path "{{var:ALICE_INVOICE_PATH}}"
    Then I see the invoice reference "alice-only-fixture"
    When I open the seeded invoice path "{{var:BOB_INVOICE_PATH}}"
    Then I see the access-denied message "You do not have access"
    And the invoice reference "bob-only-fixture" is not visible

Adapt the message to your policy. Some applications deliberately show Not found for inaccessible records. Choose that contract explicitly; do not make the assertion accept any error, spinner or blank page.

The visible denial is the main outcome. The missing invoice reference is a second check against rendering protected data alongside an error. An immediate visibility snapshot alone can pass before the page finishes loading; use web-first assertions tied to the resolved denial state.

Ask the server for its decision

The browser journey checks the user experience. It does not prove that the backing endpoint enforces the same rule. OWASP's authorization guidance calls for permission checks on requests and server-side enforcement; hiding controls is not that enforcement.

This standalone Playwright example exercises one cookie-authenticated API boundary. It assumes a configured baseURL, separate saved states for Alice and Bob, two seeded endpoints returning JSON with a reference field, and a documented 403 denial policy. The paths and schema are examples, not TestVibe endpoints.

import { test, expect } from '@playwright/test';

test('invoice reads follow ownership', async ({ playwright, baseURL }) => {
  if (!baseURL) throw new Error('Configure the authorized test target');

  const alice = await playwright.request.newContext({
    baseURL, storageState: '.auth/alice.json',
  });
  const bob = await playwright.request.newContext({
    baseURL, storageState: '.auth/bob.json',
  });
  const options = { maxRedirects: 0, failOnStatusCode: false };

  try {
    const aliceOwn = await alice.get('/api/invoices/alice-fixture', options);
    expect(aliceOwn.status()).toBe(200);
    expect((await aliceOwn.json()).reference).toBe('alice-only-fixture');

    const bobOwn = await bob.get('/api/invoices/bob-fixture', options);
    expect(bobOwn.status()).toBe(200);
    expect((await bobOwn.json()).reference).toBe('bob-only-fixture');

    const denied = await alice.get('/api/invoices/bob-fixture', options);
    expect(denied.status()).toBe(403);
    expect(await denied.text()).not.toContain('bob-only-fixture');
  } finally {
    await Promise.all([alice.dispose(), bob.dispose()]);
  }
});

Playwright's request contexts keep their own cookie state. Disabling redirects here prevents a redirect to a login page from being followed and mistaken for the intended response. A missing or expired identity must fail the positive controls, not quietly become a successful authorization test.

For bearer-token authentication, supply each identity's authorization headers through your test's credential setup. Browser local storage does not automatically become an API Authorization header. Keep saved states and tokens out of Git and shared artifacts; Playwright's authentication guide explains the state-file setup.

If your API intentionally returns 404 for inaccessible records, assert 404 instead of 403. Do not accept both merely to make the test green. Also inspect the denied response's contract for other private fields: the single fixture marker above checks one known value, not every possible disclosure.

Keep the identities separate

Run each browser scenario with the identity it names. In standalone Playwright, use separately configured contexts or role-specific fixtures. In TestVibe, describe the starting identity and sign-in steps explicitly and review the generated setup. Finishing one feature does not mean a later feature inherits its authenticated browser session.

Do not borrow an admin state file for the reader test because it makes setup easier. That changes the policy row you are exercising. Likewise, role changes and session revocation deserve their own cases with an explicit expectation for the next request; clearing cookies alone does not reproduce every server-side policy change.

OWASP describes missing checks on an individual object's access as broken object-level authorization. You do not need an enumeration script to write a useful regression. Two controlled identities and a known foreign fixture are enough to exercise a specific boundary.

Turn the matrix into a small TestVibe group

Start with the record whose exposure would matter most, then:

  1. Create a dedicated group in Tests for its authorization journeys. Use a test environment and synthetic fixtures with documented ownership.
  2. Add account credentials as project secrets and fixture paths as variables. Use references in the instructions rather than credential values.
  3. Write paired allow and deny scenarios. Name the active identity, the target record, the action and the exact visible result.
  4. Generate the browser tests, then inspect the saved instructions, code and completed verification evidence. A passing replay can still contain an incomplete assertion.
  5. Keep server-level permission checks alongside the browser group in your API or integration suite. The example above is ordinary Playwright; this workflow does not assume TestVibe discovers all endpoints or automatically creates a security matrix.
  6. Re-run the relevant cases when roles, ownership rules, routing or data-access code changes. Review unexpected failures before changing the expected denial.

Generation and replay interact with the target you provide. A denied write is still a write attempt, and a regression can turn it into an allowed one. Use disposable fixtures for those cases and verify their final state. Check browser screenshots and traces before sharing them, since secret-reference handling does not conceal everything your application renders.

Start with one invoice and two identities

Choose one record, two identities and a policy with a clear allow and deny result. Prove the legitimate access still works, then prove the other identity cannot obtain the protected data through the UI or its backing request.

That gives you a focused regression for a rule your team actually understands. It does not certify the whole application or replace security review. Keep the UI check, but make the server earn its green result too. Use the test-description guide to make that first boundary explicit.

Coming soon

Turn a described flow into a reviewable test.

Describe a flow in plain English. Generate a Playwright test, run it on your schedule, and review proposed fixes when your UI changes. Join the waitlist for early access.

  • Plain English in, real Playwright out
  • Runs in your CI, on your schedule
  • No credit card, unsubscribe anytime