Security
Evidence, not assurances.
You are regulated, so you will check this page rather than believe it. Every claim below names the control, the mechanism and the file it lives in. Where a control is not there yet, it is written down under what we do not claim — in the same detail as the controls that are.
12
Cross-tenant attacks, run in CI
A red-team suite drives real Server Actions and route handlers with another practice's identifiers. A pass means the attack failed.
4
Independent reviews, published in full
Findings first, open items listed one by one — including the two that say do not sell this yet.
25
Provider operations, each behind its own authority
Each is a provider and operation pair with its own authority variable. Absent an exact match, the call is refused before the network is touched — the default in code is refusal.
AES-256-GCM
Encryption at rest
Companies House authentication codes, HMRC tokens, NI numbers and MFA secrets. The app refuses to start without the key.
Tenancy isolation
Your practice, and nobody else’s.
Practice is the tenant root. Every tenanted table carries a practiceId, and every data-access path scopes by it server-side — in pages, in services and in Server Actions, never only in the interface. The deployment audit sampled more than twenty data-access sites and found all of them correctly scoped.
Twelve attacks, on every push
redteamCrossTenant.attack.test.ts is written from the attacker’s side. The threat model is a legitimately authenticated user of one practice — a malicious employee, or a stolen session — supplying identifiers belonging to another.
Only the session edge is mocked. Every role check, database query and audit write is real, against a throwaway PostgreSQL container provisioned for the job. It runs in the unit suite on every push to the release branch and on every pull request.
Through Server Actions
A Server Action is a public POST endpoint. Six attempts drive real actions with another practice's identifiers.
- Add a director to a company belonging to another practice
- Add a person with significant control to another practice's company
- Generate a confirmation-statement report for another practice's company
- Update another practice's client record
- Reveal the National Insurance number held against another practice's client
- Reveal a National Insurance number as staff inside your own practice — the role gate, not the tenant gate
Through route handlers
Tested separately because the route-group layout that enforces authentication does not run for a route handler.
- Export another practice's compliance report
- Download an identity document belonging to another practice
- Pull another practice's rows out of the workflow CSV export
Privilege escalation inside a practice
The insider case: a legitimate session, used for something the role does not permit.
- Escalate your own role by submitting a role change for yourself
- Change the role of a user in another practice
- Disable a user in another practice — denial of service against a competitor
The conflict wall
A wall in the query, not on the screen.
Tenancy and visibility are not the same control. Tenancy is absolute and not configurable. Visibility is the conflict wall: within one practice, which staff may see which clients. A sole practitioner and a twenty-person firm need different answers, so a practice turns the wall on deliberately.
The default is open, which reproduces the previous behaviour exactly. An upgrade never silently hides a client from someone who could see it yesterday.
It is composed into the SQL
visibilityWhereFor() returns a Prisma where fragment that is combined with the tenancy filter and evaluated by the database. It is deliberately unable to express “all practices”: every caller already applies practiceId, so the fragment can only ever narrow.
It follows the records that hang off a client
visibleViaClient() carries the same fragment to companies, deadlines, reminders and tasks. Applying it to client rows alone was proven insufficient by execution: an unassigned staff member still got the walled client’s name from a company page, from deadlines and from practice search. A barrier that leaks the client’s identity through three other doors is not a barrier.
It fails closed
A client-portal user who reaches a practice query gets an impossible filter rather than an exception, so a mis-wired caller returns nothing instead of everything.
A URL is not a list
requireClientVisible() throws rather than returning a boolean, because the boolean version sat in the codebase for a whole release with no production caller — the list was walled and the record behind it was not. The refusal deliberately does not distinguish “behind the wall” from “does not exist”: telling someone that a client they may not see nonetheless exists is most of the leak.
Where it is composed today
The client list and client record, companies, the dashboard, deadlines, corporate actions, company secretarial, chase and practice search. Where it is not yet, see what we do not claim.
Authentication
Sessions in the database, factors that cannot be quietly removed.
Authentication is bespoke and session-based rather than delegated, and every part of it is inspectable in this repository. There is no development bypass, no impersonation and no hardcoded test user anywhere in src/.
Sessions
A 32-byte random token, delivered in an HttpOnly, SameSite=Lax, Secure, Path=/ cookie named cc_session. Only its SHA-256 hash is stored, with a seven-day absolute expiry. Passwords are bcrypt.
The Secure flag is fail-closed: production always wins over an http origin, so a downgraded deployment cannot quietly send the session token in clear text.
Multi-factor, and step-up to remove it
TOTP, optional per user and enforceable per practice. A password-verified session is marked mfaPending and is refused by getCurrentUser() until the challenge completes, so a half-authenticated cookie reaches nothing.
Turning MFA off requires the current password again, and enrolment refuses to replace an active factor. Removing a second factor is a privilege-reducing act: a live session alone must not be enough, or a borrowed session strips MFA and the account falls back to password-only.
Throttling
Password attempts: eight failures in fifteen minutes, then a fifteen-minute lockout, keyed by e-mail address and separately by client IP.
TOTP attempts: five in ten minutes, keyed by user. Tighter on purpose — the attacker has already proved the password, and verification accepts a one-step window, so only about three of the million six-digit codes are valid at any moment.
Password-reset requests: five in fifteen minutes, keyed by the submitted address and by IP, so the application cannot be turned into a mail-bomb against somebody’s inbox.
Credentials never travel in a URL
An invitation or reset link carries a raw single-use credential. Generating one returns it in the POST response body, rendered on screen by a small client component — nothing navigates, so nothing is written to the address bar, to browser history and its sync, to the platform access log, or into the Referer of every subsequent request.
A source guard fails the build if any page reads a credential-shaped key out of searchParams, and a browser test confirms what a person would check: the link is on screen and the address bar is clean.
Self-service signup is off for the pilot: /signup returns 404 and the Server Action refuses. Accounts arrive by invitation, and client-portal logins are provisioned by an operator script with no self-service route at all — a portal user is external to your practice, so the pilot keeps that door shut by construction.
Data handling
Documents on a private volume. Secrets sealed, or the app will not start.
What is held is your practice’s own record: clients and contacts, company data imported from Companies House, statutory registers and their history, deadlines, corporate actions, generated documents, and the identity and evidence documents your team uploads. We hold no client money, and your data is neither sold nor shared.
Identity documents go to a private volume, not a public bucket
Identity documents, VAT evidence, filing documents and trial balances are written under storage/private on a persistent volume — outside the database and outside any publicly served directory. The deployment requirements make that volume a hard platform requirement, precisely because on ephemeral disk an upload appears to succeed and the bytes vanish.
- Paths are server-generated from CUIDs and never derived from the uploaded filename, so a malicious filename cannot influence where the file lands.
- Every read and write resolves through a path-traversal guard that throws if the resolved path escapes the storage root.
- MIME and extension allow-list, size caps, and file mode 0600.
- Served only through authenticated, tenant-scoped route handlers with
nosniffandno-store. File contents are never logged or written into an audit entry.
Encryption where the value has to be reversible
A Companies House company authentication code is password-grade, but it has to be presentable at filing time — so it is encrypted with AES-256-GCM under a random 12-byte IV in a versioned envelope, rather than hashed. The raw value is never persisted, never returned to the browser and never logged.
The same envelope protects HMRC tokens, National Insurance numbers and MFA secrets. The application throws if COMPANY_SECRET_ENCRYPTION_KEY is absent or is not 32 bytes. There is deliberately no insecure fallback, and the key is backed up as a recorded operational requirement — because a restore without it produces a database of unreadable ciphertext.
Malware scanning is mandatory in production, on every upload path
VAT evidence, bridging imports and identity documents — including the ones a client uploads through their own link, with no session — all pass the same gate. Under production it cannot be switched off: setting the disable flag throws rather than disabling it, and without a reachable scanner an upload fails closed and is refused. The deployment requirements list a reachable scanner as a platform requirement for exactly that reason, and a deployment that lacks one cannot accept uploads.
No secret has ever been committed
Verified by scanning all 8,516 objects in the full git history against the live values of every populated environment key and the service-account key. Environment files, credential files and browser session-state files are ignored, and CI fails the build if any of them becomes tracked — or if any file exceeds 25 MB.
Response headers
Applied to every route, quoted exactly.
There is no middleware or proxy layer in this application — authentication is enforced in the route-group layouts — so next.config.ts is the single place these can be applied to every response, and they are, at /:path*.
The framing pair is the important one. Without it any site could iframe an authenticated page and clickjack a Server Action, and in this product the VAT submission and the register’s “undo last apply” are one click each.
- Content-Security-Policy
- frame-ancestors 'none'
- No site can put an authenticated page in an iframe.
- X-Frame-Options
- DENY
- The same instruction for browsers that do not honour the first.
- X-Content-Type-Options
- nosniff
- An uploaded document cannot be re-interpreted as script.
- Referrer-Policy
- strict-origin-when-cross-origin
- No path or query string leaves the origin in a Referer header.
- Strict-Transport-Security
- max-age=31536000; includeSubDomains
- One year, subdomains included. Browsers ignore it over plain http, so it is safe to send always.
- Permissions-Policy
- camera=(), microphone=(), geolocation=(), payment=()
- This product handles client identity documents; powerful browser features stay off.
Availability
Two probes, and they answer different questions.
A health check that cannot tell “restart me” from “take me out of rotation” turns one database incident into a restart loop. So there are two endpoints, both unauthenticated by design because an orchestrator cannot present a session.
/healthz
Liveness
Deliberately touches no dependency. A liveness failure tells the orchestrator to restart the process, and restarting will not fix a database outage.
/readyz
Readiness
Runs a real, time-bounded database round-trip and returns 503 when the database is unreachable. This is the endpoint a platform health check must target. Its body is a machine-readable reason and a duration — never a driver message, host name or schema detail, because it is reachable without authentication.
/login is explicitly not a probe target and is named as such in the deployment document: it returns 200 without querying the database when the request carries no session cookie. That was proven in a running container with the database stopped — three consecutive 200s while nothing worked.
The process refuses to start on an incompatible schema, on missing or malformed required configuration, and on an unwritable document store. A bad deployment fails visibly instead of serving pages that quietly cannot do their job.
Independent review
Four reviews. The findings are published, not managed.
Four reviews were run before this pilot, by reviewers working independently of the people who wrote the code. Each is written up findings-first, and the items still open are listed one by one, with severities, rather than summarised away.
Technical due diligence
A fourteen-area sweep across architecture, code quality, security, legal correctness, operations, deployment, accessibility, performance, testing and commercial claims. Twenty-three findings survived adversarial verification, twenty-two of them classed as blockers. Nine were fixed in that session; the remaining fourteen are listed individually. The report also states, first, which of its own areas were left unverified.
Legal and professional review
A UK company secretary read every sentence the product generates: board and general meeting minutes, written resolutions, the filed resolution copy, pack assembly and omissions, statutory thresholds and majorities, stamp duty arithmetic, filing deadlines and their citations, the validator prose shown before approval, and the client-portal approval wording. Three critical and six high findings, each with its statutory basis.
Finding verification register
Every alleged release blocker investigated against the code, schema and runtime, then classified: confirmed, partly confirmed, not reproduced, already fixed, or intentional and acceptable. It exists so that a claimed defect and a real one are not treated alike — in either direction.
Final verification report
The results reproduced by execution against an isolated PostgreSQL container, never a development database. It confirmed some claims and overturned others, including a container build defect that turned out to be two defects rather than one.
The conclusions are not flattering, and they are published anyway
The due-diligence reviewer would deploy the product internally today, and would onboard unpaid pilot firms told plainly what is missing. Asked whether to onboard paying firms, or to recommend general availability, the answer to both is no — with named reasons, which is why this pilot is what it is.
The company secretary would rely on the generated documents as drafts to be completed, checked and signed, and not as finished documents — and says which sentences must still change before general availability. Every gap the records cannot fill is now printed as a gap, in square brackets, in the place it belongs.
A capability matrix, labelled narrowly
Alongside the reviews sits a capability truth matrix that labels each capability as production-complete, pilot-ready with constraints, advisory or read-only, sandbox, partial domain, or internal infrastructure — rather than as a feature list. The narrowest defensible claim is the one we sell against.
What we do not claim
The other side of the ledger.
A security page that overclaims is worse than none, and you would find these out in diligence anyway. So here they are, in the same detail as everything above.
No certification, and none implied
We hold no SOC 2 report and no ISO 27001 certificate. Neither is claimed, badged or described as “in progress” anywhere on this site. If a certification matters to your firm’s own supervisory obligations, we are not yet the answer.
No third-party penetration test has been performed
The red-team suite above is our own code, and the four reviews below were reviews. An external penetration test is not among them and has not been commissioned.
Pre-general-availability, in controlled pilot
This is pre-1.0 software. Only the currently deployed pilot commit is supported, and it carries no security-update guarantee. The pilot is an isolated, non-production evaluation environment. It may contain pilot-operational, demonstration and test records and must not be described or handled as synthetic-only. Treat all records as confidential and subject to the applicable access controls until a dated data-classification record establishes a narrower fact. Pilot users must not upload real client documents unless a later, explicitly authorised pilot process permits it.
The conflict wall is not yet on every surface
The technical due diligence found that /compliance, /compliance-reports and the VAT client picker still disclose every client’s name — and on the VAT picker their contact e-mail — to a walled-off staff member. Twelve surfaces were found missing the wall across three review sessions, which is why the fix on the list is structural rather than a thirteenth patch: a scoped query helper that cannot be constructed without a viewer, so forgetting it becomes a type error instead of a leak.
Rate limiting is per-process and in memory
Correct for the single-instance pilot, which the persistent document volume forces anyway. With N instances the effective limit multiplies by N, and counters reset on redeploy. A database-backed counter is the documented next step.
Malware scanning covers the evidence upload paths, and depends on a scanner being configured
Corrected 22 August 2026, and narrowed 25 August 2026 to say only what is true. The evidence upload paths — VAT evidence, bridging imports and identity documents, including those arriving through a client’s own upload link — route through one scan gate, and in a production deployment that gate fails closed: with no reachable scanner the upload is refused rather than stored unscanned. It is not yet every upload path, and saying so precisely is the point: the accounting trial-balance import stores its source file without a malware scan, and incorporation attachments are refused at transmission unless a scan recorded them clean but have no wired upload path to scan them on the way in. Those two are named here rather than covered by a blanket claim. The consequence of the gate cuts both ways for the paths it does cover: a deployment that has not been given a scanner cannot accept those uploads at all. The public link is further constrained by a 256-bit token stored only as a SHA-256 hash, a fourteen-day expiry, revocability, rate limiting, a type allow-list and a 5 MB cap.
No error-tracking service is wired in
Unexpected server errors are logged with a reference the user is shown, so a support conversation starts from a string that exists in both places. There is no aggregation product behind that.
No WCAG conformance claim
Automated axe checks (WCAG 2.1 A/AA) gate nine surfaces and a 375-pixel overflow test covers twelve pages, but keyboard order, screen-reader comprehensibility and the client portal have never been audited by a person. Until they have, no conformance claim should be made and none is.
The content security policy is frame-ancestors only
The clickjacking directive is in place. A full policy governing script and style sources has not been written yet, and it is on the list.
Dependency advisories do not fail the build
They are reported by CI and reviewed. They are not a gate.
Scheduled jobs run unattended, and their runs are not recorded
Corrected 22 August 2026 — this entry previously said nothing was scheduled, and that was wrong. Three jobs run against the pilot deployment on a daily timer: deadline recalculation at 06:00, identity-check refresh at 06:30 and the compliance sweep at 07:30. They read and write practice data with nobody present. A fourth, monitoring, is deployed but currently carries no schedule, so the Companies House filing-delta alerts it produces are not being generated. Corrected again — this entry then denied that any durable execution record was kept, and that had stopped being true. Each of those three jobs has written a receipt for every run since 30 August 2026, recording what it did, what it counted and what it alarmed on, and each run’s last outcome is exported on our internal metrics endpoint. What you cannot see is that history: the only run detail rendered anywhere in the product is for the Companies House filing poll, on the filing health page. So a failed overnight run is visible to us and not to you. That, not the absence of a record, is the open operational gap.
And one limit that is the whole point of the product
Compliance Cockpit prepares the filing — the form, the data, the statutory deadline, the supporting documents — and a person at your practice submits it. What holds that line is not an absence of code, and we corrected this page on 22 August 2026 because it used to say it was. The code that speaks to Companies House and to HMRC exists. Every one of its network calls is refused at the transport boundary unless the deployment carries a named transmission authority for that exact provider and operation, supplied as configuration on one named deployment; absent an exact match the software refuses, which is its default rather than a setting. The check sits immediately before the call, in src/lib/providerTransmissionPolicy.mjs, so it cannot be bypassed by reaching a Server Action directly. For VAT, the OAuth client is registered against HMRC’s sandbox and the variable that would point it at the live service is not set — a configuration control, which is a weaker and more honest thing to claim than impossibility. Generated documents are drafts for a qualified person to complete, check and sign, and they say so on their own face.
Responsible disclosure
Found something? Tell us privately.
The process lives in SECURITY.md in the repository, and it is short on purpose. We would much rather hear it from you than read it in somebody’s report.
- Report privately to the pilot support contact named in the pilot runbook. Please do not open a public issue.
- Include reproduction steps, the affected URL, and the time the behaviour occurred — the same three things a support reference gives us at the other end.
- The pilot is an isolated, non-production evaluation environment. It may contain pilot-operational, demonstration and test records and must not be described or handled as synthetic-only. Treat all records as confidential and subject to the applicable access controls until a dated data-classification record establishes a narrower fact. Pilot users must not upload real client documents unless a later, explicitly authorised pilot process permits it. If you believe personal or otherwise sensitive data was involved, say so first.
- Only the currently deployed pilot commit is supported. This is pre-1.0 software and carries no security-update guarantee.
Read the reports before you decide.
We will send you the technical due diligence and the company secretary’s review, and walk your IT or risk lead through anything on this page — including the parts under what we do not claim.