The security model in one page — what protects the data, what protects the evidence, and the design refusals that keep the product honest.
Data protection
| Layer | Mechanism |
|---|---|
| PII at rest | Phone/email encrypted (Fernet); lookups via keyed HMAC hashes — the DB can find a subject by exact identifier without storing it in the clear, and fuzzy search is impossible by design |
| Tenancy | Every row carries a real organization foreign key; every query is org-scoped; cross-tenant reads are structurally absent, not filtered out |
| Evidence integrity | Per-tenant HMAC signing secrets, encrypted at rest, versioned by key_id, rotatable without orphaning history — see evidence |
| Transport | TLS-only deployment posture (HSTS, secure cookies, proxy-TLS headers); webhook URLs must be HTTPS, payloads HMAC-signed with timestamp binding against replay |
| Abuse limits | Per-key API throttle (120/min), per-IP widget/subject throttle (30/min), plus the OTP-specific limits below |
Credential model
Four credentials, each unable to do the others' job: secret keys (server, scoped, sha256-stored, shown once, revocable), publishable keys (browser, widget-only), subject tokens (one person, 15 minutes, opaque, Redis-backed — merchant keys are rejected on subject endpoints), and staff dashboard sessions (role-based: viewer roles cannot mutate). Every staff mutation lands in an immutable audit log.
Identity verification for rights access
The hosted rights page proves identity with one-time codes engineered against the two abuses that matter: enumeration (unknown identifiers get an indistinguishable flow that never verifies; the rate limiter counts unknowns too, so the throttle can't become the oracle) and amplification (codes are sent only to identifiers already on file). Codes are single-use, attempt-capped; sessions are 30 minutes, single-org. The anonymous-claim flow inverts the threat (the code must reach a new identifier) so it inverts the defense: hard caps per identifier and per browser record, and only for records that actually exist.
Design refusals
- No device fingerprinting, ever. Considered for re-identifying anonymous subjects and rejected: it is probabilistic (a false match shows one person another person's record), it is itself covert tracking of the kind this product exists to regulate, and data-protection law is explicit that a controller need not collect more data merely to identify a subject. The consented alternative shipped instead: the person adds their own identifier and proves it.
- No PII in receipts — that's what makes public verification safe.
- No merchant branding on the verify page — the evidence artifact must visibly come from the evidence layer, not from the party it defends.
- Preferences outside the evidence chain — mutable UX state must never dilute the append-only legal record.
Operations
Key inventory and rotation procedures (Django secret, Fernet PII key, lookup-hash key, tenant signing secrets, API keys, webhook secrets) live in the repo's docs/SECURITY-RUNBOOK.md — tenant signing rotation is one call and safe anytime; old secrets are retained because chain verification of history depends on them. Disclosed pre-launch posture: staff TOTP/2FA and the automated 72-hour SDAIA breach workflow are on the roadmap, not shipped; production KSA residency is a deployment decision pending.