Security
Security at Zovo
Zovo Holdings LLC · Last reviewed: 28 September 2026
Zovo handles tips for hospitality workers. That means guests' payments, workers' earnings and a property's payroll data. This page describes the controls that protect them. We have tried to write it so that a technical reader can check every claim rather than trust it.
Card data never reaches our servers
Card numbers, expiry dates, security codes and wallet tokens go directly from the guest's browser to Stripe. Payments are created as Stripe Checkout Sessions and Payment Intents; our systems receive a reference and an amount. There is no card data in our database, our logs or our backups, because there is no code path that could put it there.
We are not a card-data environment, and we designed it that way from the first commit rather than descoping into it later.
Payment events are authenticated
Every message Stripe sends us is signature-verified before it is acted on. Verification never fails open: an unsigned or wrongly-signed message is rejected with a 400, and if no signing secret is configured at all, everything is rejected. During a domain migration two endpoints run at once and both must verify independently.
Signature failures are logged with the number of configured secrets — never their values — so a misconfiguration is visible instead of appearing as silent payment loss.
Secrets cannot reach a browser, and we prove it rather than assert it
Two automated tests run on every change:
- An allowlist of which source modules may reference which secret. A blanket ban is not honest — the tipping page genuinely needs to check that a Stripe key is present — so instead each secret is pinned to the files permitted to name it. Widening that list is a deliberate, reviewable change.
- A render scan that serves every page with the secrets replaced by distinctive sentinel values and fails if any sentinel appears in any response body or header. A page that errors during the scan counts as a failure, not a pass.
Secrets are stored in Cloudflare's secret store and are never in the repository.
Identifiers are hashed with a key, not just hashed
Every IP address and user-agent string we retain is stored as an HMAC-SHA256 digest computed with a secret key, never as a plain hash. This matters: there are only about four billion IPv4 addresses, so an unkeyed hash of an IP address can be reversed by brute force in minutes. A keyed hash cannot, without the key.
Worker sign-in sessions are stored the same way — only the SHA-256 of the session cookie is kept, so a copy of the session table cannot be replayed as a login.
Access control
Managers and platform staff sign in through Cloudflare Access with a one-time PIN. Our application verifies the signed token itself on every single API request — it does not trust the network path — and then maps the proven email address to a role in our own database. Proving who you are and deciding what you may do are separate steps.
Every member acts only within their own property and the units beneath it. A record outside that scope returns "not found" rather than "forbidden", so the API does not confirm the existence of data you cannot see.
Workers sign in separately, by SMS code to their phone. The two sign-in paths never cross. Pausing or offboarding a worker revokes every one of their live sessions in the same database transaction, so a lost phone is signed out immediately rather than at the next expiry.
Defence against card testing
Stolen cards are tested against small payment forms. Three independent layers sit in front of ours: per-IP and per-tag burst limits at the edge, sliding windows in a stateful coordination object, and a block that stops an address after three declines in a row. Each fails open independently, so a fault in the defences degrades protection rather than stopping legitimate tips. Behind them, an hourly job flags unusual volume for human review.
Every block is recorded for review.
Auditing
Every change made through the dashboard is written to an audit log: who did it, what changed, when, and a keyed hash of the origin IP. Rows are only ever added, never edited or deleted, with two deliberate exceptions — both of which remove personal data rather than alter the record: the origin IP hash clears after 180 days, and a worker's details are cleared from the earlier rows about them when that worker asks to be deleted.
Billing documents are logged in full — every line of the invoice that was rendered, as it was rendered — so what a customer received can be reconstructed rather than recomputed.
Backups
The database is copied nightly to encrypted object storage, separate from the primary database provider, so the loss of one vendor's account is recoverable. Restore steps are documented and kept with the code. Backups expire automatically after 13 months.
Data is deleted on a schedule, by a job, not by intention
Personal identifiers are removed by a nightly task rather than by someone remembering to do it. Guest identifiers on a payment clear after 180 days and on a page view after 90; a departed worker's phone number clears 90 days after they leave; sign-in codes and dead sessions are deleted outright. Payment and payroll records themselves are kept for their statutory period, which is the point of separating the two. Every period described on this page lives in one place in the code, and a test compares each one against our written policy so the two cannot drift apart.
Browser protections
The manager dashboard and the worker-facing pages are served with a Content-Security-Policy, X-Content-Type-Options: nosniff and a strict referrer policy. Inline scripts on those pages run under a per-response nonce.
Where this does not yet apply: the guest tipping page and the thank-you page carry none of those three headers. Both embed a small inline script — the payment button and the poll that waits for a payment to clear — that predates the nonce mechanism, and applying the Content-Security-Policy without first rewriting them would stop guests from being able to pay. The other two headers have no such obstacle; they are simply not set there yet. Closing both is tracked work, not an oversight, and we would rather say so here than claim a control we have not finished.
All user-supplied text is escaped at render time on every page, including the guest pages.
Tags cannot be quietly rewritten
Each tag we ship is write-protected with its own password, derived from a secret key and the tag's id. No password is stored anywhere and no two tags share one, so a password learned from one tag opens nothing else. Tags carry a printed serial, and staff check at the start of a shift that nothing has been stuck over their tag. We do not claim that a tap proves which physical tag was read — that needs chips we have not shipped yet.
Tipping pages are rate-limited per network address before anything is looked up, so the space of tag ids cannot be walked to collect workers' names.
Google access and uploaded logos
When a property connects its Google Business Profile, the access token is stored sealed with its own key, the sign-in exchange is signed so it cannot be forged, and disconnecting revokes the token with Google. Uploaded logos are checked by their actual bytes (PNG, JPEG or WebP only — never SVG) and served from our own domain under a policy that stops them running anything.
Responsible disclosure
If you find a security issue, email support@zovotipping.com. Tell us what you found and how to reproduce it. We read every report and will reply as soon as we reasonably can; we are a small team, so please allow a few days. Please do not run automated scans against production or access data that is not yours; we will not pursue anyone who reports in good faith and gives us a reasonable chance to fix the issue before disclosing it.
Contact
Zovo Holdings LLC — support@zovotipping.com