Screen customer documents without ever storing one

How the system is built, where it runs, and what it deliberately does not do.

Every free PDF tool on the internet leads with “your file never leaves your browser”. This one does not, because it cannot, and a page that dodged that would be the wrong page to put on a security site. So the honest version is first, and everything after it is the detail behind it.

Why this runs on a server

A browser-only checker is a real and good design, and it buys exactly one property: the bytes stay on your machine. What it costs is depth. The analysis here walks the cross-reference tables, replays page content streams to recover paint order, decodes covered text through font /ToUnicode maps, and compares a document against structural baselines built from genuine issuer specimens. That is a parser with a hard CPU budget and a kill switch, not something to run inside the tab of whoever happens to have opened the page.

So the trade is deliberate: the file is sent over TLS, and in exchange the answer is one no in-browser metadata reader can produce. What we owe you for that trade is that the file's stay is as short and as contained as we can make it, which is the rest of this page.

If that trade is wrong for your document, do not make it. A file under seal, a live incident, an unredacted brief before filing, for those, the right tool is one that runs on your own machine, and today that is not this one. We would rather say so here than have you find out afterwards.

What happens to a document you upload

The whole lifetime of an uploaded file, in order:

An uploaded document exists only in memory. The one thing that reaches disk is a metering row that holds no document.
IN MEMORY, INSIDE THE REQUEST arrives over TLS held in memory type sniffed from the bytes worker thread, one of two 15-second hard deadline report returned to you buffer discarded ON DISK No temporary file, no upload directory, no object store, no queue behind the endpoint. meters your quota one ledger row key id · account id · byte count risk band · timestamp

The buffer never becomes a file: nothing about the document survives the response, not the bytes, not the extracted text, not the filename. The privacy policy is the column-by-column version of the row on the right.

  1. It arrives over TLS and is held in memory. There is no temporary file, no upload directory, no object store and no queue behind the endpoint.
  2. Its type is decided by sniffing the bytes, never by the Content-Type header or the filename, so a hostile caller cannot choose which parser runs on their input.
  3. The buffer is handed to a worker thread, one of two, with a 15-second hard deadline. A file that exhausts it has its worker killed and replaced, and the caller gets 422 analysis_timeout. When both workers are busy and the queue is full the answer is 503 busy rather than an ever-growing latency tail.
  4. The report is serialised and returned. The buffer is discarded. Nothing about the document survives the response, not the bytes, not the extracted text, not the filename.
  5. One ledger row is written, and it holds no document: a key id, an account id, a byte count, a risk band and a timestamp. That is what meters your quota.

Uploads are capped at 10 MB. Every other route on the origin is capped at 64 KB, so nothing except the one endpoint that needs headroom can be made to buffer megabytes before its handler runs.

Recovered text. The words found underneath a failed redaction. Is returned to you, in the response, and stored nowhere. It is the finding, so withholding it would make the report useless; and keeping it would make this the most sensitive database in the product. It is neither logged nor persisted.

The privacy policy is the column-by-column version of what the database holds.

Proving later what we told you

Nothing is stored, which means we cannot reproduce a report. If you decline an application on our signals and are asked six months later to justify it, "re-run the file" is not an answer: the file is the applicant’s, the engine has moved on, and we kept nothing.

So every /api/v1/inspect response carries a receipt: a signature over the exact body we returned, with the engine version and the time we issued it. You keep the report. If a dispute arrives, the report you produce either verifies against that signature or it does not.

"receipt": {
  "version": "v1",
  "issuedAt": "2026-08-15T19:32:25.147Z",
  "engineVersion": "1.30.0",
  "reportHash": "7543fa3f36bc24d0c5588b87025408b8a84fc7f7259d7dea3d9856a707af76ef",
  "redacted": false,
  "signature": "…"
}

Two limits, both worth knowing before you rely on it. The signature is an HMAC, so only we can verify it: a third-party auditor has to come through us and take our word that we checked honestly. A published-key signature would remove that objection and is the intended v2. And the receipt covers the report, not the document: it proves what we said, never what the file was.

API reference has the field and the verification endpoint.

Where it runs

  • One server, in Finland. A Hetzner instance in Helsinki (hel1). Compute and the database are in the EU.
  • No inbound port is published. The origin is not reachable from the internet at all. Traffic arrives through a Cloudflare Tunnel, which makes an outbound connection from the box; there is no listening socket for anyone to scan, and no firewall rule standing between you and a mistake.
  • TLS everywhere. Terminated at Cloudflare's edge, carried to the origin inside the tunnel. HTTP is redirected, and the canonical host is tamperlens.com.
  • The database is a single SQLite file on that box's disk, holding accounts, key hashes, sessions and the usage ledger. It has never held a document, because no code path writes one.

Application hardening

  • A real Content-Security-Policy. default-src 'self' with no 'unsafe-inline' anywhere on the site. Every page we author loads its script and its stylesheet as external files, and CI fails the build if an inline script or style appears under src/web. The single exception is /docs, where Swagger UI injects its own bootstrap and offers no way to configure it away; it is scoped to that one prefix, which renders no user content.
    The policy is self-only apart from Cloudflare, and only for the two features that need it: challenges.cloudflare.com in script-src and frame-src for the Turnstile human check, and static.cloudflareinsights.com plus cloudflareinsights.com for Web Analytics. Each entry appears only while that feature is switched on, so a deployment running neither keeps a byte-identical self-only policy. Both are on today, and the privacy policy §4 says what they load and when.
  • Cross-origin uploads are refused. A browser may only call /inspect from our own pages. Requests with no Origin header (curl, server-side SDKs) are unaffected, because they spend their own address rather than borrowing a visitor's.
  • Rate limits keyed on the network, not the header. Anonymous callers get 10 inspections an hour and 25 a day per IPv6 /64 (one customer is one bucket, not 264 of them), under a deployment-wide daily ceiling. Keyed callers are metered by their monthly quota instead.
  • Other headers: Referrer-Policy: no-referrer, Cross-Origin-Opener-Policy: same-origin, X-Content-Type-Options: nosniff, HSTS in production, and frame-ancestors 'self'.
  • Encrypted PDFs are disclosed, not attacked. A password-protected file is reported as “not content-analysed” rather than scanned as ciphertext and reported as clean. We do not attempt to break protection on anything you send.

Accounts, keys and secrets

  • API keys are stored as SHA-256 hashes. The key itself exists in our systems only for as long as it takes to show it to you once. That is also why we cannot resend it: rotation issues a new one and invalidates the old.
  • Passwords are scrypt hashes with a per-account random salt. The plaintext is never stored and the hash is not reversible.
  • Sessions are HttpOnly, Secure and SameSite=Lax, expire after 30 days, and are deleted on logout. A password reset invalidates every other session.
  • Card data never reaches us. Checkout and the billing portal are Stripe-hosted; we hold an opaque customer id and nothing else.
  • One key, one tenant. Private issuer baselines resolve against the owning account only. There is no code path from one account's request to another's profile.

Availability and recovery

Sixty days of backups exist, and none of them contains a document, because the database they copy has never held one.
the document discarded with the response: it occupies no time at all one ledger row kept, so the meter can count · key id · account id · byte count · risk band backups, on the box 48 snapshots, one every six hours backups, off the box pulled once a day to an operator-controlled machine off the provider 0 12 days 60 days Neither copy is separately encrypted at rest. That is listed under “What we do not have”, below.

The bars are retention, not volume. Losing the server does not also mean losing its backups, which is the whole reason the daily pull exists.

  • Deploys are gated and reversible. A push to main runs typecheck, build and the full test suite; only on green does a runner on the box rebuild, wait on the container's healthcheck, and roll back automatically if the new build is unhealthy.
  • Database backups every six hours, 48 snapshots retained (twelve days), written to the same box in a directory only the service account can read, and pulled once a day to an operator-controlled machine off the provider, kept there for sixty days, so losing the server does not also mean losing its backups. They contain no documents, because the database does not, and in both places they are not separately encrypted at rest, which is listed below among the gaps rather than dressed up here.
  • Uptime is probed externally on a best-effort schedule, and errors are reported to Sentry with the request path, never with a document.
  • What the probe found is public, at status.tamperlens.com: 90 days of daily availability per component, every incident, and the raw data the percentages are derived from. It is probed from outside our network and served from outside it too, so it stays up when we do not. The commitment there is 99.5% monthly, and the page states the limit of its own measurement: one vantage point, at an interval it measures and publishes rather than claims, so an outage shorter than the gap between two checks can pass unseen.

Who else touches it

None of them receives an uploaded document, because the document does not outlive the request that carried it. This table is the same list as §6 of the privacy policy, which is the authoritative version, if the two ever disagree, the policy is the one that binds.

Nothing that came out of the document crosses the line. What the six subprocessors see is metadata about the request, not the file it carried.
INSIDE THE PROCESS, IN HELSINKI OUTSIDE IT the uploaded bytes the text extracted from them text recovered from under a redaction All three are discarded with the response. No code path writes any of them anywhere. the report goes back to you, in the response the trust boundary request metadata Cloudflare request metadata and IP Hetzner the physical host only Stripe your billing details Resend your email address Sentry stack traces, request paths GitHub nothing about you none of them receives a document

The same six rows as the table below, which is in turn §6 of the privacy policy: the authoritative version if the two ever disagree.

Provider What it does What it sees
Cloudflare DNS, TLS, tunnel ingress, the Turnstile human check, Web Analytics Request metadata and IP address, in transit
Hetzner The server and its disk The physical host; no application-level access
Stripe Checkout, subscriptions, billing portal Your billing details. We never see card data
Resend Confirmation and password-reset email Your email address and the message
Sentry Error reporting Stack traces and request paths
GitHub Source, CI, deploy trigger Nothing about you; no production data

Answering your security questionnaire

If you are evaluating Tamperlens for a company that reviews its vendors, you almost certainly have a spreadsheet to fill in. Two things exist so that you do not have to schedule a call to fill it in.

An MVSP self-assessment. MVSP is a minimum-bar checklist maintained in the open by a group that includes Google, Salesforce, Okta, Slack and Netflix. We answer every control in it, including the ones we fail, and the failures are the same ones listed in the next section rather than a different, gentler set. It is a self-assessment: nobody audited it, and it is not an attestation of anything.

A CAIQ Lite response. The Cloud Security Alliance's short questionnaire, answered the same way, for reviewers whose process expects that format as a file they can attach to a ticket.

Both are available on request from [email protected]. Normally the same working day, because they are written documents rather than a project. Where an answer depends on something you can check yourself, it points at the page that shows it: this one, the privacy policy, or the status page.

If a completed SOC 2 report is a hard requirement, we are not a fit today, and we would rather say so in writing than spend a quarter of your time discovering it. The self-assessments above are what a one-person vendor can honestly offer instead; they are not a substitute, and we do not present them as one.

What we never look up

Tamperlens reads Brazilian identifiers: CPF, CNPJ, an NF-e access key, a boleto's linha digitável, a PIX payload, a CNJ process number, and checks them the only way it knows how: arithmetic. Each of those numbers carries check digits computed by a published identity, mod 10, mod 11 or ISO 7064. We recompute them from the digits already printed on the page, and report the ones that disagree with themselves.

The whole check is arithmetic over digits you already sent. There is no branch in it that reaches a bureau, because no such branch was built.
digits already on the page CPF · CNPJ · NF-e access key linha digitável · PIX · CNJ recompute the check digit mod 10 · mod 11 · ISO 7064 integer arithmetic, same parse disagrees with itself → reported agrees → stays silent ask a bureau what is known about the person photographs · addresses · relatives · companies no such code path no request leaves the process A passing check digit proves essentially nothing, so only the failures are reported.

Six identifier families, all integer arithmetic, and no network call anywhere in the module. This is a description of what the code does, not a policy that could be relaxed.

Nothing is queried, because there is nothing to query. No bureau, no dossier database, no lookup by CPF, and no request leaving the process. The check is integer arithmetic over digits you already sent us, and it runs inside the same in-memory parse as everything else on this page. It is worth stating plainly, because the obvious way to build a Brazilian document product is the opposite one: take an identifier and return what is known about the person behind it . Photographs, addresses, relatives, companies. That product carries a data broker's LGPD exposure, a data broker's cost base and a data broker's regulatory surface. This one holds no personal data because it acquires none.

It buys less than a lookup would, and the report says so. A failed check digit is good evidence of a typo, an OCR error or an invented number. A passing one proves essentially nothing (a valid CPF is eleven digits anyone can generate in a loop, and every template filler already does), so this family reports failures and stays silent on successes. It is not identity verification, not a KYC check and not a background check, and we do not sell it as one.

What we do not have

A security page that lists only strengths is marketing. These are the questions a procurement review will ask where the answer today is no.

  • No SOC 2, ISO 27001 or HIPAA attestation. Tamperlens is a small, independently run product and has not been audited against any of them. If your process requires a certified vendor, this is not one yet.
  • No multi-region redundancy. One server, one region. A hardware failure is downtime, not data loss, but it is downtime.
  • No customer-managed keys and no on-premise build. There is currently no way to run the engine inside your own network.
  • Backups are not encrypted at rest. The six-hourly snapshots sit on the same server's disk, in a directory readable only by the service account that writes them, and inside Hetzner's own backups of the box. That is file permissions, not cryptography. They hold no documents, but they do hold account rows and key hashes.
  • No penetration-test report to share. The hardening above is real and much of it is enforced by tests in CI; none of it has been reviewed by an external assessor.
  • Metadata forensics is evidence, not proof. A cleanly regenerated PDF launders away byte-level signals, and exiftool -all= strips every image identity finding. A match is strong evidence; an absence is none, and every finding says so in its own prose.

Reporting a vulnerability

Email [email protected] with enough detail to reproduce it. You will get a human reply. There is no bounty programme and no legal threat: report it in good faith, give us a reasonable window to fix it, and we will credit you if you want the credit.

Please do not run automated scanners against the production host. The rate limits above will simply refuse you, and the noise buries the report you were trying to make.