Security by design, not by declaration
Every QR is encrypted at rest and never public by default — a viewer must pass an access gate before anything decrypts. This page explains exactly how, down to the encryption primitives and the access model, so a security reviewer does not have to take our word for it.
// 01 · encryption model
Three-layer envelope encryption
Every payload passes through three nested key layers before it reaches storage. Compromising any single layer does not expose the data — an attacker needs all three.
// layer 1 · KMS
Master Key (AWS KMS / HSM)
Hardware-backed. Never leaves the HSM. Used only to wrap and unwrap per-tenant DEKs.
// layer 2 · per-tenant DEK
Data Encryption Key (AES-256-GCM)
One key per tenant. Stored encrypted in the database — wrapped by the master key. The decrypted DEK lives only in process memory with a 5-minute TTL; it is never written to Redis, disk, or any at-rest store.
// layer 3 · per-QR payload
Ciphertext (AES-256-GCM + AAD)
Each QR's content is encrypted with a fresh 96-bit IV. The QR's own ID and its tenant ID are bound as GCM Associated Authenticated Data, so the ciphertext cannot be moved to a different record or tenant and still decrypt.
// 02 · key properties
What makes the encryption model robust
DEK never at rest
The plaintext tenant DEK lives only in process memory. A full Redis dump or database backup yields only wrapped ciphertext — unusable without KMS access.
AAD binding
Each ciphertext is bound to its qr_id and tenant_id as GCM Additional Authenticated Data. Relocating a ciphertext to another record or tenant causes decryption to fail.
Per-tenant key isolation
Every tenant has a distinct DEK. Even if two tenants share the same KMS master key, their encrypted DEKs are separate ciphertexts. Cross-tenant decryption cannot succeed.
Nonce budget managed
Random 96-bit IVs are safe up to roughly 2^32 encryptions per key. DEK rotation is triggered by both manual action and an encryption-count threshold, so high-volume tenants never approach the birthday bound.
// 03 · authentication
Opaque server sessions, not client JWTs
Session state lives on the server (Redis + database). The browser holds only an opaque httpOnly cookie — it carries no claims the client can read, tamper with, or replay. On every request, the server resolves user, tenant_id, role from the session record.
// what the server holds
- Session record (user ID, tenant ID, role, expiry)
- Short access token TTL: 15 minutes
- Refresh token rotation on each use
// what the browser holds
- One opaque cookie (httpOnly, Secure, SameSite=Strict)
- No tenant ID, no role, no user ID in client storage
- No self-contained JWT in the URL
// 04 · access gates
Every QR is gated by default
An owner picks one or more gates per QR. Scanning a gated QR with no active session or valid gate reveals nothing — not even that content exists.
Login and authorization
A team member with an active session and the right role and tenant sees the content directly — no separate gate needed for people already inside the tenant.
Magic link (default)
A time-limited, single-use-capable link at /a/<id>. Opaque and stateful — revocable at any time, independent of its expiry.
PIN code
An owner-set PIN, stored only as a hash. Verification is rate-limited per opaque ID and IP, with a lockout after repeated wrong attempts.
Email one-time passcode
A short-lived code sent to a verified address, with a capped number of wrong attempts before it is invalidated.
Optional public mode — off by default, opt-in per QR
For redirect-style QRs (campaign links, dynamic shorteners), an owner can explicitly opt a single QR into public mode, which drops the gate for that QR only. The destination is still stored encrypted at rest under the same AES-256-GCM scheme with qr_id and tenant_id bound as AAD — public mode removes the access check, not the encryption. Every QR defaults to a gated mode; nothing becomes public without a deliberate, per-QR action.
Magic links as opaque stateful IDs
Sharing a QR with a viewer generates an access link at /a/<id>. The ID is short, random, and opaque — it is not a self-contained JWT. A JWT in the URL leaks through browser history, CDN and proxy access logs, and the Referer header. An opaque ID leaks nothing.
Stateful
The server tracks expiry, use count, and revocation status. The link can be revoked at any time, independent of its expiry date.
Use-limited
An optional max-use cap prevents a link from being replayed indefinitely. The use counter is incremented atomically on each successful access.
No-referrer
Referrer-Policy: no-referrer is set on all /a/* and /q/* routes. The token is also stripped from the URL after the first exchange.
// 05 · data isolation
Multi-tenant isolation
Row-level tenant scoping
Every tenant-scoped table carries a tenant_id column. All queries are scoped to the resolved tenant from the session — there is no path from one tenant's session to another tenant's rows.
PostgreSQL Row-Level Security
RLS policies on the database enforce tenant boundaries as defense-in-depth. A query that bypasses the application layer still cannot return rows from a different tenant.
// 06 · privacy and operations
Auditable, exportable, erasable on request
HMAC-hashed IPs in access logs
Access logs never store raw IP addresses. Each IP is hashed with HMAC-SHA256 using a server-side secret key. Plain SHA-256 would be reversible over the IPv4 space (only 2^32 possible values); HMAC with a secret key is not.
Audit logging
QR creation, updates, and deletion; user invites and role changes; key rotation; billing changes; and access-link creation and revocation are all recorded to a tenant-scoped audit log.
GDPR data export
A tenant owner can request a full export of their data, including QR records and metadata, directly from their account — no support ticket required.
Crypto-shredding for erasure
Erasure destroys the tenant's DEK first. Without it, every payload encrypted under that key becomes permanently unrecoverable, then the remaining metadata is hard-deleted — an irreversible action gated behind a typed confirmation.
// 07 · transport and at-rest summary
What is encrypted, and where
| Data | Protection | Notes |
|---|---|---|
| QR payloads | AES-256-GCM (tenant DEK) | qr_id + tenant_id bound as AAD |
| Tenant DEK | KMS-wrapped | Plaintext DEK never written to disk or Redis |
| User email | AES-256-GCM (dedicated PII key) | Lookup via HMAC hash, not plaintext |
| Passwords | argon2id (one-way) | Never stored reversibly |
| IP addresses | HMAC-SHA256 (server secret) | Access logs only; raw IP never persisted |
| Access link IDs | Hash of opaque ID stored | Raw ID never written to database |
| In transit | TLS 1.3 (Cloudflare) | HSTS preload; certificate transparency monitoring |
// 08 · vendor assessment
Questions a security review asks
The questions a compliance or procurement reviewer typically raises, answered directly — no certifications claimed that we do not hold.
- Yes. Every QR payload is encrypted with AES-256-GCM before it reaches storage, under a key unique to the owning tenant. The database and any backup contain only ciphertext.
- No standing path exists. The only code that calls decrypt is the same gated viewer flow every end user must pass — there is no admin or support endpoint that returns plaintext. The decrypted key material used for that single request lives in server process memory only, for the duration of the request, and is discarded after.
- An attacker gets ciphertext and a KMS-wrapped data encryption key (DEK) — both useless without the KMS master key. The plaintext DEK is never written to Redis, disk, or any at-rest store; it exists only transiently in application memory.
- A magic link is a short, random, opaque ID resolved server-side to a stateful record — not a self-contained JWT, so there are no claims to leak. Referrer-Policy: no-referrer is set on every /a/* and /q/* response, and the token is stripped from the URL after the first exchange.
- Yes, at three layers: every tenant has a distinct data encryption key; every ciphertext is bound to its qr_id and tenant_id as GCM additional authenticated data, so a ciphertext relocated to another tenant fails to decrypt; and every tenant-scoped table carries a tenant_id enforced by PostgreSQL row-level security as defense-in-depth.
- A time-limited magic link is the default. Owners can additionally require a PIN (hashed, rate-limited with a lockout after repeated failures) or a one-time email passcode (5-minute expiry, capped attempts). A QR can also be opted into public mode, which drops the gate for that QR only while keeping the payload encrypted at rest — every QR is gated by default and public mode is a deliberate, per-QR opt-in.
- Not yet in the managed cloud offering — the KMS master key is provisioned in our cloud account, tightly scoped to encrypt/decrypt only. Self-hosted deployments already let you supply your own master key end to end. A customer-managed-key (BYOK) option for the managed product is on the roadmap, not shipped today.
- Yes. A tenant owner can request a full data export or trigger erasure from their own account. Erasure destroys the tenant's data encryption key first (crypto-shredding, making every existing ciphertext permanently unrecoverable) and then hard-deletes the remaining metadata — an irreversible action gated behind a typed confirmation.
- Yes. QR creation, updates, and deletion; user invites and role changes; key rotation; billing changes; and access-link creation and revocation are all recorded to an audit log scoped to the tenant.
- Not at this time. We describe the controls we actually have — envelope encryption, per-tenant key isolation, no plaintext-at-rest, revocable stateful access links, audit logging — and will not claim a certification we do not hold. If your assessment requires one, contact us to discuss the roadmap.
// 09 · contact
Report a vulnerability
If you find a security issue, please contact us directly before public disclosure. See our Security Policy for scope, safe-harbour terms, and response SLAs. We acknowledge reports within 3 business days and work with researchers toward a responsible fix.