Skip to content

The access-control flaw that would have let one client see another's payroll

A pre-launch security audit found a broken-access-control flaw that let a crafted request pull another tenant's payroll records, rated and fixed before the product ever reached a customer.

Findings register

Pennyfold Payroll · pre-launch assessment · rated against OWASP ASVS with a written impact statement

Export report Fri 24 Jul 2026EV
Critical1Cross-tenant data exposure
High33 before launch
Medium62 before launch
Low40 before launch
All findings146 must close before launch · 8 after

By severityAll areas

14 findings · reproduction + fix on each
IDSeverityFindingAreaASVSSequence
F-01CriticalPayroll run returned for any tenant's run IDAccess controlV4.2.1Pre-launch
F-02HighDatabase admin console reachable from public subnetConfigurationV1.14.1Pre-launch
F-03HighDefault credentials on job-queue dashboardConfigurationV2.5.4Pre-launch
F-04HighReachable prototype pollution in form parserDependenciesV14.2.1Pre-launch
F-05MediumStack traces returned on 500 responsesConfigurationV7.4.1Pre-launch
F-06MediumTLS 1.0 and 1.1 accepted on legacy listenerConfigurationV9.1.3Pre-launch
F-07MediumPayroll viewer can export employee bank detailsPermission modelV4.1.3After
F-08MediumNo rate limit on password reset requestsAuthenticationV2.2.1After
F-09MediumSession cookie set without SameSiteConfigurationV3.4.3After
F-10MediumReachable ReDoS in date-format libraryDependenciesV14.2.1After
F-11LowServer version disclosed in response headersConfigurationV14.3.3After
F-12LowHSTS max-age shorter than one yearConfigurationV14.4.5After
F-13LowPassword policy skips breached-password checkAuthenticationV2.1.7After
F-14LowSeven advisories in unreachable packagesDependenciesV14.2.1After
Sequence6 pre-launch · 8 after go-live

F-01 Top finding

Rated alone at the top
CriticalAccess controlClosedPayroll run returned for any tenant's run IDImpact statementAny signed-in user of any tenant can read another company’s payroll run by naming its ID. After launch: a notifiable incident across every tenant.Reproduction
session d.ashworth · tn_4F1AGET /api/v1/payroll-runs/pr_7KD2WQpr_7KD2WQ owned by tn_9C07→ 200 OK · Ostwick Dental Group
Specific fixScope the run lookup to the session's tenant_id; refuse with 403.ASVSV4.2.1OwnerM. HaddadFix merged23 JunRetest02 Jul · 403

Who, what, and how long

Industry
Professional Services
Duration
2 weeks
Cooperation model
Fixed price
Services
Application reviewDependency & permission auditRemediation plan
Integrations
HubSpotDocuSignXeroGoogle Workspace
Technologies
Manual code reviewBurp SuiteDependency scanningOWASP ASVSThreat modelling
Team
1 Project lead2 Frontend engineers1 Backend engineer

Client name withheld under NDA. Engagement details are shown to the extent our agreement permits.

What went wrong, and when

The application had never been formally assessed, and launching with real payroll and personal data across multiple tenant companies raised the stakes of any undiscovered access-control gap considerably.

The application authenticated well and authorized incompletely. Endpoints checked that a request carried a valid session, and in one case didn't check that the object it named belonged to that session's tenant. So a crafted request with a valid login and another company's record ID returned that company's payroll. Per-endpoint testing doesn't find this, because every endpoint passes its own test.

We reviewed the application for broken access control, examined server configuration and exposed services, audited the dependency tree for known CVEs, and reviewed the permission model. That surfaced a critical flaw where a crafted API request could return another tenant's payroll records. We rated it as the top finding and delivered a prioritized remediation plan the team closed before launch.

Process

Phase by phase

  1. Phase 1: Review logic

    Across tenant boundaries

    Reviewed application logic and access control specifically across tenant boundaries, beyond per-endpoint checks.

    • Access-control review
    • Tenant isolation findings
  2. Phase 2: Assess surface

    Config and services

    Assessed server configuration and exposed services for hardening gaps.

    • Configuration review
    • Exposure map
  3. Phase 3: Audit dependencies

    Known-vulnerable components

    Audited the dependency tree for known CVEs and reachable vulnerable paths.

    • Dependency report
    • Reachability analysis
  4. Phase 4: Rate and plan

    In order of severity

    Rated every finding by severity and delivered a prioritized remediation plan the team closed before launch.

    • Severity-rated findings
    • Remediation plan
    • Retest report
Effective permissions

Resolved from what each handler enforces, not what the policy declares · 6 roles × 11 objects

Export matrix Fri 24 Jul 2026EV
LegendRW read + writeR readown own recordX export* any tenant’s rowsExcess within tenant · F-07Scoped to own tenant

Critical Before the patch

Replay · 10 Jun 2026
Role \ objectCompanyEmployeesPay runsPayslipsBank detailsTax codesPensionsTimesheetsReportsUsersAudit log
tenant_ownerRWRWRW*RRRWRWRRRWR
payroll_adminRRWRW*RWRWRWRWRWR——
payroll_viewerRRR*RRXRRRR——
accountantRRR*R—RR—R——
line_manager—R—————RWR——
employee—own—ownown—ownown———

Closed After the patch

Retest · 02 Jul 2026
Role \ objectCompanyEmployeesPay runsPayslipsBank detailsTax codesPensionsTimesheetsReportsUsersAudit log
tenant_ownerRWRWRWRRRWRWRRRWR
payroll_adminRRWRWRWRWRWRWRWR——
payroll_viewerRRRRRXRRRR——
accountantRRRR—RR—R——
line_manager—R—————RWR——
employee—own—ownown—ownown———

Declared · policy for payroll_runs

roles.policy
role payroll_viewer  allow payroll_runs:read    scope: tenant  allow payslips:read        scope: tenant  allow bank_details:read    scope: tenant  allow bank_details:export  scope: tenant # F-07# every role declares scope: tenant# nothing here says whether a handler applies it
Read declared, the model was correct: every role scoped to its tenant. Every endpoint passed its own authorisation test.

Resolved · GET /api/v1/payroll-runs/:runId

F-01
auth   session valid              appliedpermit payroll_runs:read         appliedscope  tenant_id = session       not applied- SELECT * FROM payroll_runs WHERE id = $1+ SELECT * FROM payroll_runs WHERE id = $1 AND tenant_id = $2# mismatch → 403 · retest 02 Jul 2026 10:14:31
Resolved from the handler, Pay runs read reached any tenant’s rows for every role that could read it. The fix was one line and a retest.
On screen

Effective permissions as actually resolved: six roles against eleven objects before and after the patch, and the one handler that never applied the tenant scope its policy declared.

The numbers, before and after

1 (cross-tenant data exposure)

Critical findings surfaced

14

Total findings rated and prioritized

Yes, 4 days before go-live

Critical finding closed before launch

One critical and fourteen total are counts from the assessment. The pre-launch closure is verified by retest, not by the team's report of the fix. Severity ratings follow OWASP ASVS with a written impact statement per finding, so the ordering reflects consequence, not category.

Client name withheld under NDA. Figures are approximate, drawn from the engagement’s own reporting.

Introduction

The engagement

The team was two weeks from launching a multi-tenant payroll platform and wanted a security assessment before onboarding its first paying customers.

A multi-tenant payroll platform two weeks from onboarding its first paying customers, never formally assessed. The stakes are what made the timing worth paying for: payroll and personal data for multiple client companies in one database, where a boundary failure isn't a bug. It's a notifiable incident affecting every tenant at once.

Security Audit & Vulnerability Assessment

The solution

How it was handled

04
  1. 01

    Reviewed application logic and access control across tenant boundaries

    Every endpoint was replayed with a valid session from one tenant and identifiers belonging to another: the axis per-endpoint testing doesn't cover.

  2. 02

    Assessed server configuration and exposed services for hardening gaps

    Configuration review ran alongside the code review, so a hardening gap and the logic it exposes could be reported as one finding.

  3. 03

    Audited the dependency tree for known-vulnerable components

    The dependency tree was resolved to its transitive leaves and filtered to what's actually reachable, so an unreachable build-time CVE didn't bury the real findings.

  4. 04

    Rated every finding by severity and delivered a prioritized remediation plan

    Every finding carries a reproduction, a specific fix and a written impact statement, sequenced into must-close-before-launch and after.

Cross-tenant review

Access control examined specifically across tenant boundaries, where the flaw was.

Most access-control testing checks whether a user can reach an admin page. This looked at the axis that actually mattered for a multi-tenant payroll product: whether one tenant could reach another's data. Every endpoint was exercised with a valid session from tenant A and identifiers belonging to tenant B, which is how the critical finding surfaced: an object reference the API authenticated but never scoped.

What shipped
  • Every endpoint replayed with tenant A's session and tenant B's IDs
  • Found an object reference authenticated but never scoped
  • Cross-tenant access tested as its own axis
Replay trail · cross-tenant

Every endpoint replayed with tenant A's session and tenant B's identifiers · in time order

Replay again Fri 24 Jul 2026EV
SessionTenant A · Brackley Joinery LtdIdentifiers fromTenant B · Ostwick Dental GroupWed 10 Jun 2026

Audit trail

api/v1 · tenant B ids
TimeRequestTarget idStatusResult
09:51:12GET /employees/:idemp_3HX81404Scoped
09:51:19GET /employees/:id/bankemp_3HX81404Scoped
09:51:27PATCH /employees/:idemp_3HX81404Scoped
09:51:34GET /payslips/:idps_V20KQ404Scoped
09:51:41GET /pay-schedules/:idsch_0P4M404Scoped
09:51:49GET /pensions/:idpen_71CD404Scoped
09:51:56GET /tax-codes/:idtax_5RN2404Scoped
09:52:02GET /payroll-runslist200Scoped
09:52:08GET /payroll-runs/:runIdpr_7KD2WQ200Exposed
09:52:15GET /payroll-runs/:runId/linespr_7KD2WQ403Scoped
09:52:21POST /payroll-runs/:runId/approvepr_7KD2WQ403Scoped
09:52:29GET /reports/:idrep_Q8L3404Scoped
Retest · Thu 02 Jul 2026 · after the fix merged 23 Jun 2026
10:14:31GET /payroll-runs/:runIdpr_7KD2WQ403Closed
Session tenant Id owner404 / 403 on another tenant’s id = scoped

09:52:08 · the crafted request

F-01
Request
GET /api/v1/payroll-runs/pr_7KD2WQCookie: pf_session=s_tn_4F1A…e41cX-Request-Id: rq_0610_095208# valid login, Brackley Joinery Ltd# run id copied from Ostwick Dental Group
What the handler checkedSession is validcheckedRole grants payroll_runs:readcheckedRun belongs to the session's tenantnever askedResponse · 200 OKOstwick Dental Group · May 2026pr_7KD2WQ · 38 employeesGross £96,412.50A. WhitcombeAssociate dentist••-••-41£4,862.17P. NdlovuDental nurse••-••-07£1,988.40H. CarrowHygienist••-••-83£2,418.60R. MistryPractice manager••-••-26£2,731.05L. FenwickReceptionist••-••-58£1,604.92
On screen

The replay trail in time order: every endpoint called with tenant A's session and tenant B's identifiers, the crafted request at 09:52:08 that returned another tenant's payroll run, and the 403 at retest that closed it.

Dependencies & exposed surface

Transitive tree matched to CVE feeds and filtered to reachable paths · configuration reviewed alongside

Export SBOM Fri 24 Jul 2026EV
Packages resolved1,284To the transitive leaves
Advisories matched9CVE feeds
Reachable2Reported one by one
Patch batches31 before launch
Public listeners12From the public subnet
Closed at edge93 kept public

Advisories by reachability

Unreachable ≠ launch blocker
Packages resolved1,284Advisories matched9Reachable2
PackageViaAdvisoryReachableBatch
urlform-parse 2.3.1transitiveHighYes · F-041
chronofmt 1.8.0directMediumYes · F-102
xmlsax-lite 0.7.2transitiveHighNo path · F-142
tarball-extract 4.1.0transitiveMediumNo path · F-142
yamlish 3.0.5transitiveMediumNo path · F-142
bundlewright-plugin-svg 2.2.0build-timeHighNo path · F-143
globwalk 5.0.1build-timeMediumNo path · F-143
devproxy-mw 1.4.8build-timeMediumNo path · F-143
semverish 2.0.0build-timeLowNo path · F-143
Batch 1Before launch1advisoriesThe reachable highBatch 2After go-live4advisoriesReachable medium + runtime transitiveBatch 3After go-live4advisoriesBuild-time only

Exposed surface

Public subnet
PortServiceAt the edge
:443Web app · load balancerPublic
:443API · load balancerPublic
:80Redirect to HTTPSPublic
:5050Database admin consoleF-02Closed
:3001Job-queue dashboardF-03Closed
:8443Legacy API listenerF-06Closed
:5432PostgreSQL primaryClosed
:6379Redis · sessionsClosed
:22SSH · app hostsClosed
:9090Metrics serverClosed
:9100Node exporterClosed
:8080Staging previewClosed
Exposed servicesF-02Admin console on :5050Default credentialsF-03Queue dashboard :3001Error verbosityF-05Stack traces on 500TLS settingsF-061.0 / 1.1 on :8443
On screen

Nine advisories matched across 1,284 packages, filtered to the two that are reachable and sequenced into three patch batches, beside the twelve listeners reachable from the public subnet and which of them were closed at the edge.

Dependency and config audit

Known-vulnerable components and hardening gaps assessed alongside application logic.

The dependency tree was resolved to its transitive leaves and matched against CVE feeds, then filtered to what's actually reachable in this application. An unreachable vulnerability in a build-time package isn't a launch blocker, and reporting it as one buries the findings that are. Server configuration was reviewed alongside: exposed services, TLS settings, error verbosity and default credentials.

What shipped
  • Transitive dependency tree resolved and matched to CVE feeds
  • Filtered to reachable code paths, so the report stays actionable
  • Exposed services, TLS, error verbosity and defaults reviewed
Remediation plan

Every finding with its fix, owner and the day it closed · sequenced into must-close-before-launch and after

Export plan Fri 24 Jul 2026EV
4 daysPlan delivered19 JunF-01 fix merged23 JunRetest · critical closed02 JulGo-live06 JulLast finding closed23 Jul

Plan

14 findings · fix, owner, day closed
IDSeverityFindingSpecific fixOwnerMergedClosed · retest
Must close before launch · 6 · go-live Mon 06 Jul 2026
F-01CriticalPayroll run returned for any tenant's run IDScope the run lookup to the session's tenant_id; refuse with 403M. Haddad23 Jun02 Jul
F-02HighDatabase admin console reachable from public subnetClose at the edge; reach through the bastion onlyS. Lindqvist22 Jun02 Jul
F-03HighDefault credentials on job-queue dashboardRotate, put behind SSO, close at the edgeS. Lindqvist22 Jun02 Jul
F-04HighReachable prototype pollution in form parserPatch batch 1: upgrade to 2.3.4, pin transitiveR. Okafor24 Jun02 Jul
F-05MediumStack traces returned on 500 responsesGeneric error body; trace to logs with request idM. Haddad25 Jun02 Jul
F-06MediumTLS 1.0 and 1.1 accepted on legacy listenerRemove the listener; TLS 1.2+ on the edgeS. Lindqvist26 Jun02 Jul
After go-live · 8
F-07MediumPayroll viewer can export employee bank detailsSplit bank-detail export into its own permissionM. Haddad08 Jul10 Jul
F-08MediumNo rate limit on password reset requestsPer-account and per-IP limits with backoffJ. Pryce09 Jul14 Jul
F-09MediumSession cookie set without SameSiteSameSite=Lax on the session cookieJ. Pryce09 Jul14 Jul
F-10MediumReachable ReDoS in date-format libraryPatch batch 2: upgrade to 1.8.3R. Okafor13 Jul16 Jul
F-11LowServer version disclosed in response headersStrip the version at the edgeS. Lindqvist13 Jul16 Jul
F-12LowHSTS max-age shorter than one yearmax-age=31536000; includeSubDomainsS. Lindqvist13 Jul16 Jul
F-13LowPassword policy skips breached-password checkCheck against a breached-password listJ. Pryce17 Jul21 Jul
F-14LowSeven advisories in unreachable packagesPatch batches 2 and 3, no code path affectedR. Okafor20 Jul23 Jul

Severity-rated remediation

On screen

The remediation plan: every finding with its fix, owner and the day it closed, split into must-close-before-launch and after, with the critical one closed four days before go-live.

Every finding rated and sequenced, so the critical one was unambiguous.

Findings were rated against OWASP ASVS with an explicit impact statement, never just a severity word, and sequenced so the team knew what had to close before launch and what could follow. The cross-tenant flaw was rated alone at the top, with everything else ordered beneath it. Each entry carried a reproduction and a specific fix, not a category and a link.

What shipped
  • Rated against OWASP ASVS with a written impact statement
  • Sequenced into must-close-before-launch and after
  • Every finding carries a reproduction and a specific fix

Working inside their operation

A cross-functional team of 3 worked on a fixed price basis over 2 weeks, covering Application review, Dependency & permission audit, Remediation plan. We ran a weekly demo and kept a shared board they could check at any time. Their team took over day-to-day operation before the engagement ended, with handover built into the last phase.

Two weeks, fixed price, ending in a rated and sequenced report. Cross-tenant access was tested as its own axis, separate from role testing, and that's the decision that surfaced the critical finding. The remediation plan was ordered by what had to close before launch, and the team closed the critical one four days ahead of go-live.

What it settled

What changed in the runbook

03
  1. 01

    The critical finding was in the boundary between tenants, the place per-endpoint testing doesn't look.

    Each endpoint passed its own authorization test. The flaw lived in what none of them checked, which is why the boundary needs testing as a boundary.

  2. 02

    Two weeks before launch is the cheapest this class of finding will ever be to fix.

    The same finding after launch is a notifiable incident across every tenant. Two weeks earlier, it was a one-line change and a retest.

  3. 03

    Rating the findings mattered: an unranked list of twenty invites the team to start with the easiest.

    An unranked list of fourteen gets worked from the top by whatever is quickest. Rating them is what put the cross-tenant flaw first instead of eleventh.

One request, two builds

A valid login and another company’s run ID

The crafted request from the replay trail, followed through the handler before the fix and after it: what it carried, what was checked, and what came back. Switch builds or steps, or use the arrow keys once a tab is focused.

Replay · Wed 10 Jun 2026 · 09:52:08

A real login from one company and an identifier copied from another. Nothing about the request is malformed, and it is byte-for-byte the same as the one the replay sent. Only the handler changed.

Tenant A · Brackley Joinery LtdTenant B · Ostwick Dental Group
GET /api/v1/payroll-runs/pr_7KD2WQCookie: pf_session=s_tn_4F1A…e41cAccept: application/json# session: d.ashworth · payroll_admin · tn_4F1A# pr_7KD2WQ is owned by tn_9C07
Path through the handler
  1. RequestSession tn_4F1A · id from tn_9C07
  2. Tenant checkNot performed
  3. Response200 · Ostwick Dental Group's payroll
Architecture

From a replayed request to a finding closed by retest

Per-endpoint testing passes an endpoint that authenticates and never scopes. The assessment ran the boundary as a boundary, and carried every finding through to a verified close.

  1. 01 · Trigger
    Cross-tenant replayEvery endpoint called with a valid session from tenant A and identifiers belonging to tenant B, tested as its own axis, separate from role testing.
  2. 02 · Collect
    Code, config and dependency treeConfiguration reviewed alongside the code, and the dependency tree resolved to its transitive leaves and matched to CVE feeds.
  3. 03 · Rate
    OWASP ASVS + impact statementDependencies filtered to reachable code paths; every finding rated with a written impact statement, never just a severity word.
  4. 04 · Record
    Sequenced remediation planEvery entry carries a reproduction and a specific fix, ordered into must-close-before-launch and after.
  5. 05 · Deliver
    Retest reportClosure verified by retest, not by the team's report of the fix. The critical one closed four days before go-live.

So one client never sees another’s payroll

Tenant isolation, rated findings & verified closure

The tenant boundary tested as a boundary

Every endpoint was replayed with one tenant's valid session and another tenant's identifiers. Each endpoint had passed its own authorization test; this is the axis that found the object reference authenticated but never scoped.

The critical finding can't sit eleventh

All fourteen findings were rated against OWASP ASVS with a written impact statement and sequenced into must-close-before-launch and after, so the cross-tenant flaw stood alone at the top.

Closed means retested

Pre-launch closure was verified by retest, not by the team's report of the fix. The critical finding was closed four days before go-live, before the first paying customer was onboarded.

Could one of your customers read another’s data with a request nobody has tried? Scope your build in 3 minutes.

Scope your build
Have a project?

Let's talk

Running a large platform, shaping a first MVP, or getting a product ready for a funding round? Tell us where you are. We'll shape the process around it, and stay with you after launch.