Skip to content

Security

Everything in the browser is public. We built for that.

The launcher, the publishable key, the session token and the hosted scanner itself are all readable by anyone who opens dev tools. Treating any of them as a secret would be a design error, so we do not. The security boundary is the server, and the blast radius of every browser-side value is deliberately tiny.

This page describes what is implemented today, and — just as importantly — what we deliberately do not rely on.

Implemented

Server-side controls

Six mechanisms do the actual work. Each is enforced in the API, where a modified client cannot reach it.

Publishable and secret key separation

A publishable key (pk_) can do exactly one thing: create a scan session. It cannot read a result, list your sessions, change your scanner settings, add a domain or touch your credits. That is why it is safe to paste into your HTML — not because it is hidden.

A secret key (sk_) is the trusted one and never leaves your server. We store only its SHA-256 hash, so the full value is returned exactly once, at creation, and cannot be recovered afterwards — not by you and not by us.

Domain allowlist on publishable keys

Session creation with a publishable key is rejected with 403 unless the request's Origin header is on your allowlist. A key lifted from your page and used from somewhere else is inert.

The same list bounds which origins a session may be embedded in and post its result to, so a session cannot be told to hand its document to an origin you never approved.

Short-lived, single-use, signed client tokens

The hosted scanner is authenticated by a compact HMAC-signed token carrying the session id, tenant id, issue and expiry times and a unique jti. It has a TTL of minutes, not hours.

The token is single-use for the upload: it is bound to the session that minted it, and presenting it a second time returns 409 token_already_used. A token captured from a URL cannot be replayed to inject a second document.

Signed, expiring result URLs

Result files are served from a URL whose signature is the authorisation. There is no session, cookie or bearer token involved, and there is nothing to guess: change the file id or the expiry and the request is a 403.

URLs are short-lived by design, and files themselves are dropped after 24 hours by default. A link pasted into a chat log stops working.

Webhook signing

Every delivery carries X-Scanner-Signature: t=<timestamp>,v1=<hmac_sha256(secret, timestamp.body)>. Verify it with a constant-time comparison and reject timestamps outside a few minutes, and you have both authenticity and replay protection.

The signing secret is rotatable from the console. Rotation affects future deliveries only, so you can deploy the new secret before or after rotating without dropping events.

Rate limiting and validation

Session creation is rate limited per tenant. Uploads are size-capped and rejected with 413 above the limit. Customization is validated server-side before it is stored, so an invalid accent colour is a 400 rather than a broken camera UI in production.

Data flow

Where the document actually goes

Detection is the part that would normally justify shipping a document to a server. Ours does not need to.

On the user's device

Camera access, live edge detection, contour finding, the perspective transform, contrast adjustment and PDF assembly all run in the browser. No frame is streamed anywhere. There is no server-side inference step, because the detection is geometric rather than learned — your documents are not training data.

Over the wire, once

One HTTPS upload of the finished PDF, authenticated by the single-use client token and bound to its session. Nothing else about the document leaves the device.

On our side, briefly

The file is held so you can fetch it, and dropped after 24 hours. Access is via signed, expiring URLs only. If you would rather we never persist it at all, relay-only mode forwards the document to your endpoint and keeps nothing.

Deliberately not relied upon

Things that look like security and are not

Each of these gets proposed regularly. We are explicit about rejecting them, because a control you believe in but that does not work is worse than no control at all.

Not relied upon Why
Hiding the publishable key or the session token in obfuscated JavaScript Anything in the browser can be read. We made both values safe when extracted instead: one only opens sessions from your origins, the other expires in minutes and works once.
Client-side signature or integrity checks A modified client simply deletes the check. Every decision that matters is made on the server, where the client cannot reach it.
Anti-debugging and DRM theatrics It breaks real browsers, annoys real users, and stops nobody who is actually trying. We do not ship it.
CORS as an access control Our API reflects the caller's origin and carries no credentials. CORS is a browser convention, not a permission system; the enforcement is key validation and the origin allowlist inside the handlers.

Honest limits

What is not done yet

A security page that lists only strengths is a marketing page. These are the gaps we know about.

  • Storage. Results currently live in the API process with a TTL rather than in object storage with server-side encryption and lifecycle rules. Signed URLs and the 24-hour TTL apply either way, but this is the next thing we are moving.
  • Rate limiting. The sliding window is in-process, so it is per-instance rather than global. Correct for a single deployment; it becomes a shared store before we scale horizontally.
  • Authentication on the console. Email and password with scrypt hashing and httpOnly session cookies. No SSO, no two-factor, no audit log yet.
  • Content scanning. Uploads are size-capped and content-typed, but we do not yet run magic-byte validation or antivirus over them.
  • Certifications. None. We are not going to imply otherwise.

Questions your security review will ask

Send them over. We would rather answer a hard questionnaire honestly than have you discover a gap after you have shipped.