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.
0
Code paths that can file
The Companies House filing client throws and performs no network I/O. The pilot omits HMRC credentials entirely.
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.tsis 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 wherefragment 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
For VAT evidence, scanning cannot be switched off: under production, setting the disable flag throws rather than disabling it, and without a reachable scanner an upload fails closed. The deployment requirements list a reachable scanner as a platform requirement for that reason.
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 environment holds synthetic data, and pilot firms are asked not to upload real client documents.
The conflict wall is not yet on every surface
The technical due diligence found that /compliance, /compliance-reportsand 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.
The public identity-document upload link is not malware-scanned
Unlike VAT evidence, and it is not rate-limited either. It is mitigated by a 256-bit token stored only as a SHA-256 hash, a fourteen-day expiry, revocability, a type allow-list and a 5 MB cap — mitigation, not equivalence.
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.
Nothing schedules background jobs on the deployment target
The scheduled jobs exist and run on demand; nothing yet runs them on a timer in the pilot environment. The due diligence names this as the largest unaddressed operational gap.
And one limit that is the whole point of the product
No code path in this build can transmit a filing to Companies House: the filing client throws and performs zero network I/O. Compliance Cockpit prepares the filing — the form, the data, the statutory deadline, the supporting documents — and a person at your practice submits it. For VAT, the pilot deployment omits the HMRC credentials entirely, so a live submission is structurally impossible rather than merely switched off. 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.mdin 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 deployment holds synthetic data only, and testers are asked not to upload real client documents. If you believe real 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.