Back-End & Infrastructure

Your Untested Codebase Hides Security-Privacy Trade-Offs

You inherited an untested application, and a small UX change is ready for QA. The tempting plan is to verify the new screen and leave the old backend alone. I would block that release until its authorization and data-exposure paths have tests. A clearer interface can make a previously obscure action easier to find, repeat, or misuse; visual correctness is weak evidence that the action is safe.

A passing UI test can conceal an authorization failure

Do Not Rewrite an Untested UI Before Mapping Its Journeys gets the sequencing right, but I would put an authorization check ahead of a complete journey map: an undiscovered screen is less urgent than a discovered endpoint that returns another user’s data. Your first question is not whether the new button reaches the right page. It is whether the request behind that button still enforces identity and ownership when the button is bypassed.

Legacy applications often decide what to display in a controller or template while a separate handler decides what a request may access. That division is dangerous when a UX change introduces a direct link to an existing resource, because hiding the link never prevented a caller from constructing its URL. Inspect the network request generated by the new action. Then repeat it while signed out, signed in as its owner, and signed in as another user. For an application with distinct staff and customer roles, add both roles; a customer-to-customer check cannot establish what a staff account may do.

Seed 3 accounts in an isolated test environment: an owner, a different ordinary user, and a privileged user. That is a deliberately small fixture, not a claim of coverage. Give the owner a distinctive document marker and attempt to retrieve it with the other user’s session. Check the response body as well as the status: an error page that embeds the document title has still disclosed information. Treat an unexpected redirect to a login page as a finding, too, because it may mean the test session was never valid.

Here is a runnable Playwright test for one such route. Save it as auth.spec.js, install @playwright/test and Chromium, and provide the five named environment variables for a disposable test system. The cookie must belong to the non-owner; the ID and marker must belong to the owner.

import { test, expect } from '@playwright/test';
test('another user cannot read an account document', async ({ browser }) => {
  for (const k of ['BASE_URL', 'COOKIE_NAME', 'USER_B_COOKIE', 'USER_A_ID', 'USER_A_MARKER'])
    if (!process.env[k]) throw new Error(`Missing ${k}`);
  const context = await browser.newContext();
  await context.addCookies([{ name: process.env.COOKIE_NAME, value: process.env.USER_B_COOKIE,
    domain: new URL(process.env.BASE_URL).hostname, path: '/' }]);
  const page = await context.newPage();
  const response = await page.goto(`${process.env.BASE_URL}/accounts/${process.env.USER_A_ID}/documents`);
  expect([403, 404]).toContain(response.status());
  expect(await page.locator('body').innerText()).not.toContain(process.env.USER_A_MARKER);
  await context.close();
});

Run it with npx playwright test auth.spec.js after adapting the route to the application. A 403 or 404 is an acceptable contract here only if your application consistently uses it for denied access; an unauthorized 200 is not made safe by an empty-looking page. Keep the cookie out of source control and CI logs, because a captured test session can grant the same access as its user. OWASP ASVS 4.0.3 supplies published verification requirements for access control, but your application’s roles and resources determine the actual assertions.

Better observability can make private data easier to copy

When an old codebase has little test coverage, adding telemetry feels like the fastest route to understanding failures. I would not enable broad session replay or automatic form capture first, because those tools can turn a one-person defect into a searchable collection of other people’s inputs. The risk is especially high during UX work: new fields, validation messages, and confirmation screens may expose data that earlier telemetry rules were never written to handle.

Before adding Sentry, PostHog, or another client-side recorder to the changed journey, submit synthetic sensitive values and inspect what leaves the browser in its Network panel. Check request payloads, query strings, DOM snapshots, exception messages, and the destination account. PostHog’s autocapture: false can be a useful starting configuration for manually approved events, but it does not inspect custom events written by your team. Redaction must be tested against the emitted payload, because a field renamed in the UI can evade a selector-based masking rule.

The same scrutiny applies to backend diagnostics. OpenTelemetry spans are useful for correlating a UI action with its server request, yet span attributes containing full URLs may preserve search terms or record IDs. A route template such as /accounts/{id}/documents is less revealing than a full request URL because it retains the operation without copying its parameter values. For a new event, record the action name, outcome, and a trace identifier; add a user identifier only if a specific investigation needs it and access to the telemetry store is controlled.

Set an initial 24-hour retention limit for raw debug events as a policy value to revisit with the team, rather than assuming the analytics vendor’s default fits your data. Short retention reduces the time an accidental capture remains available, but it also narrows your investigation window. That trade-off should be explicit in the release decision. During QA, inspect a failed run’s Playwright trace before uploading it: screenshots, page snapshots, and network details may include credentials or personal data even when the assertion itself is harmless.

Use OWASP ZAP against the test deployment for a bounded check of exposed endpoints, but do not mistake its scan for an authorization test. A scanner without two valid user identities cannot reliably prove that one user was denied another user’s document. Keep a separate inventory of each new UX action, the HTTP request it sends, and the telemetry it emits; that inventory gives you a reviewable record of both the access decision and the data copied to observers.

Contract tests beat journey tests for a narrow security gate

There are two useful options, and they answer different questions. Playwright journey tests win when you need to verify that a real user can find an action, understand its result, and stay within the intended role; they cost browser setup, test data, and maintenance when labels or layout change. pytest API contract tests win when you need fast checks across many identities, resources, and HTTP methods; they cost you confidence about what the browser actually sends and what a denied user can see on the page. Neither replaces the other, because the browser and the server can fail independently.

For the inherited codebase, I would build a small contract matrix before attempting exhaustive end-to-end coverage. Each row should name a resource, an action, a requester role, and the expected result. Include read and write operations separately: denying a cross-account document view does not prove that a forged update request is denied. If the application uses cookies for authentication, send write requests from another site context or with missing CSRF protection in a test environment, because a browser can attach a cookie to a request the user did not intend to make.

Give the first CI security gate a 15-minute budget to tune after observing your own runner, rather than claiming that every legacy suite should finish in that time. Spend that budget on the highest-impact changed route and its immediate neighbors: the resource lookup, its update or download action, and any endpoint used to populate a new search or autocomplete control. A new search box deserves particular attention because typing partial names can make an overly broad backend query easier to exploit than the old interface allowed.

Use failures as evidence, not as prompts to weaken the assertion. If the server returns 200 for a non-owner but filters the document in JavaScript, fix the server-side authorization because the response has already crossed the boundary. If it returns a safe error but includes a record title in a JSON error field, remove that field because status codes do not erase response bodies. Log the denied request’s trace identifier, not its full session cookie, so an engineer can investigate without making the test log another credential store.

Incremental UX is safer only when the browser boundary stays visible

Add UX without a rewrite in a legacy backend codebase argues for a practical delivery path, but “small” is not a security property: a new autocomplete request can reveal more than an old full-page form. Trace which server response supplies each new interface element. Then ask whether a user can request that response directly, whether its result is scoped to their role, and whether its fields exceed what the interface needs to display.

Do not treat a frontend permission check as the source of truth. A disabled button helps explain what the user cannot do, but it does not prevent a direct POST, a replayed request, or an older client from sending one. Server-side checks should use the authenticated principal and the requested resource on every sensitive action. If a team proposes moving those checks into a new frontend layer to avoid touching legacy code, I would refuse that shortcut because the existing endpoint remains reachable without the new layer.

Cache behavior is another late discovery. A personalized response may be correctly authorized on arrival and still appear to the wrong person through a shared cache or on a shared device. Inspect Cache-Control on document, account, and search responses; use no-store for especially sensitive content when retaining it in a browser cache would be unacceptable. Do not apply that directive indiscriminately to every asset, because removing useful caching raises load without improving the confidentiality of public files. Test a sign-out followed by the browser Back button and a second user’s sign-in on the same test browser.

Cookie settings need the same practical check. Secure limits a session cookie to HTTPS, HttpOnly prevents ordinary JavaScript from reading it, and SameSite=Lax changes when browsers send it on cross-site requests; none of those flags alone proves that a state-changing route resists CSRF. If the old application relies on cross-site sign-in or an embedded flow, tightening SameSite can break that flow, so test the actual callback before changing production settings. A Content Security Policy in Report-Only mode can reveal scripts an incremental UI depends on, but inspect reports for sensitive URLs before sending them to a third party.

When performance pressure arrives, choose a local threshold rather than borrowing a benchmark: a proposed 250 ms ceiling for the new lookup’s test-environment response is a number to tune against measured behavior. Do not remove authorization checks to meet it, because an unauthorized fast response is a regression, not an optimization. Profile the resource query and authorization lookup separately so the team can see where time is spent.

The first release decision should rest on one denied request

Start with the changed action that exposes the most sensitive resource. Create two valid users, capture the request made by one, and replay it for the other against an isolated environment. Save the status, response body, cache headers, and telemetry payload as test evidence without saving either session secret. If that request is denied cleanly, automate it and move to the next action; if it is not, the UI release has a concrete blocker.