Security
This page describes mechanisms that exist in running code. Where a control is missing or weaker than it sounds, that is stated rather than omitted.
Veridia has not been penetration-tested, audited, or certified by anyone. Nothing on this page is a third-party attestation. It is a description of implementation, offered so you can evaluate it yourself.
API keys
Two families, distinguished by prefix, with genuinely different capabilities.
| Prefix | Where it belongs | Can do |
|---|---|---|
qv_pub_ / qv_pubt_ | Browser, mobile app, page source | POST /v1/verify/init, POST /v1/verify/submit |
qv_sec_ / qv_sect_ | Server only | The above, plus reading verdicts |
The t variants are the test-mode keys. Note the shape: it is qv_pubt_, not qv_pub_test_.
Reading a verdict requires a secret key. GET /v1/verify/{id} rejects a publishable key with 401 secret_key_required before doing anything else. The reason is simple: a publishable key is visible to anyone who opens your page, so if it could read verdicts, anyone could read the KYC outcome of any verification they could name.
How keys are stored
The raw key value is shown once, at creation. What the database keeps is a SHA-256 hash of it, plus an AES-256-GCM–encrypted copy. The encrypted copy exists for one specific reason: on revocation, the raw value has to be recovered in order to delete the matching entry from the edge cache, so that revocation actually propagates instead of only being recorded.
Revocation is immediate — and there is no rotation
Revoking a key deletes its edge entry at once. Every in-flight request using it starts failing in the same instant. There is no rotation flow, no grace period, no "old key keeps working for N minutes".
The safe sequence is: create the new key, deploy it everywhere, verify traffic is flowing on it, and only then revoke the old one. Revoking first will take your integration down.
allowedOrigins does not do what its name suggests
Each key carries an allowedOrigins list. Read this before treating it as an access control.
- An empty list allows every origin. Keys are created with an empty list, so by default the check does not run at all.
- It only applies to browsers. The check is only evaluated for publishable keys on requests that carry an
Originheader. A server-side client sends noOriginand passes unconditionally. - It matches the bare hostname. The entry is
app.example.com, nothttps://app.example.comand nothttps://app.example.com:443. Wildcards like*.example.comare supported, and do not match the apexexample.comon their own.
What this feature is good for: constraining which pages your widget runs on. What it is not: a control that keeps your publishable key from being used elsewhere. Treat the publishable key as public, because it is.
Tenant isolation
Every authenticated request resolves to exactly one tenant, taken from the API key. There is no tenant parameter in any request body — sending one has no effect.
Reading a verification compares its owning tenant against the caller's, and answers 404 verification_not_found rather than 403 when they differ. A 403 would confirm that the identifier exists and belongs to someone else; a 404 reveals nothing.
Upload authentication
Captured images do not go to presigned object storage. They are uploaded to a dedicated endpoint on the API itself, which is why that endpoint authenticates differently from every other one:
- The bearer API key is not accepted there. Authentication is a short-lived
X-Veridia-Upload-Token, scoped to one verification and valid for 15 minutes. - That token is returned inside each upload slot's
headersobject from/v1/verify/init. Forward those headers verbatim; do not reconstruct them by hand. - The body must be a real JPEG — the magic bytes
FF D8 FFare checked — and at most 8 MB.
Routing bytes through the API rather than straight to storage is what makes it possible to bind the uploaded image to the verification that requested it.
Rate limiting
Two independent layers.
| Layer | Limit | Applied |
|---|---|---|
| Per IP | 100 requests / 60 s | Before authentication, on every route |
Per tenant, /v1/verify/init | 60 / minute | After authentication |
Per tenant, /v1/verify/submit | 30 / minute | After authentication |
Per tenant, GET /v1/verify/{id} | 600 / minute | After authentication |
It runs before authentication, so a 429 can arrive before any tenant exists. And because each image is a separate upload — a verification with an active liveness challenge makes roughly 20 of them — several mobile users behind the same carrier-grade NAT can exhaust 100 requests per minute while your per-tenant numbers still look untouched. If you are debugging a 429 that makes no sense against the per-tenant table, this is usually why.
Webhook signatures
Every delivery carries:
Veridia-Signature: t=<unix_seconds>,v1=<hmac_sha256_hex>
Veridia-Event: verification.approved
Veridia-Event-Id: evt_<hex>
The MAC is HMAC-SHA256(secret, "<t>." + raw_body_bytes). Two consequences worth internalising:
Verify over the raw bytes. Parsing the JSON and re-serialising it produces different bytes and therefore a different digest, even if every key and value is identical. Capture the body before your framework touches it.
The timestamp lives inside the signature header. There is no separate timestamp header, and no X-Veridia-Signature — the header has no X- prefix.
Why a 5-minute tolerance is enough
Retries stretch over roughly 12.6 minutes, which naturally raises the question of whether the replay window has to be widened to match. It does not.
The dispatcher computes a fresh signature on every attempt. The sixth attempt carries a t stamped moments before it is sent, not one from twelve minutes earlier. A 300-second tolerance accepts every legitimate retry.
Do not widen it. Every extra minute is extra time in which a captured delivery can be replayed against you, bought for no benefit.
Comparing signatures
Compare in constant time — crypto.timingSafeEqual, hmac.compare_digest, hash_equals.
One trap: crypto.timingSafeEqual in Node throws when the two buffers differ in length. An attacker who sends v1=ab turns your handler into an uncaught exception and a 500. Check the lengths first and reject on mismatch, then compare.
The SSRF guard on webhook URLs
A webhook URL is attacker-supplied data: any tenant types one into the panel, and the backend then makes an outbound request to it. Without a guard, that turns the platform into a probe for infrastructure the tenant cannot otherwise reach — the response body never returns to them, but the status code and timing are enough to enumerate what exists.
Every URL is checked when saved and again immediately before every send. The second check is the one that matters: a hostname that resolves publicly when saved can resolve to 127.0.0.1 a minute later.
The check rejects:
Any scheme but https | Payloads carry identity PII; plaintext transport is refused outright |
| Any port but 443 or 8443 | Other ports are far more often an internal service than a real endpoint |
user:pass@host credentials | A classic way to make a URL read as one host and resolve to another |
Loopback (127.0.0.0/8, ::1) | |
Link-local (169.254.0.0/16) | Where cloud instance-metadata endpoints live — the highest-value target on the host |
| Private ranges (RFC 1918 and IPv6 equivalents) | |
Carrier-grade NAT (100.64.0.0/10) | Not "private" by the standard library's definition, but never a legitimate public endpoint |
| Unspecified, multicast, reserved | |
IPv4-mapped IPv6 (::ffff:127.0.0.1) | Unwrapped and re-checked, or it would slip past everything above |
If a hostname resolves to any forbidden address, the whole name is rejected even when other answers look fine — a name resolving to both a public and a private address is the shape of a rebinding attack, so partial acceptance is not safe.
Deliveries do not follow redirects, and time out at 10 seconds total with a 5-second connect timeout.
Practical consequence for local development
http://localhost:3000 cannot receive webhooks. Not "is discouraged" — it is refused, on scheme and on address. Use a tunnel that terminates TLS on a public hostname.
Transport
The API is served over TLS. Webhook delivery is TLS-only and plaintext is rejected by the guard above.
Controls that do not exist
Stated plainly, because a declared gap is honest and a hidden one is what sinks an audit.
- No application-level encryption of identity columns.
extracted_name,extracted_document_number, andextracted_date_of_birthare stored as plain columns in MySQL. Whatever encryption exists beneath that is a property of the disk and the infrastructure provider, not a control Veridia implements or can attest to. Do not describe it as "encrypted at rest" in your own documentation on our account. - No key rotation with an overlap window. See above — revocation is immediate.
- No consent capture. The widget does not present, record, or timestamp any consent. See GDPR.
- No self-service erasure or export. See Data retention.
- No published egress IP list. If your firewall needs to allowlist inbound webhook sources, there is no list to give you today.
webhook_log.attemptsis approximate. The per-attempt history table records an attempt count that can be inaccurate under concurrent delivery. Use the delivery outbox shown in the dashboard, not that column, as the record of what was delivered.
Reporting a vulnerability
Report privately rather than in a public issue. See Support.