Security Features
AttestID protects each step of a verification, from your backend's connection to the API through to the result you act on. This page explains what each protection does and what, if anything, you need to do.
| Protection | Where it runs | What you need to do |
|---|---|---|
| Mutual TLS | Your backend → AttestID | Required. Present a client certificate on every API-key request |
| Signed verification results | Your backend | Verify result.attestation before acting on verified |
| Signed webhooks | Your webhook receiver | Verify AttestID-Signature (and optionally AttestID-JWS) |
| Device-bound capture signing | Browser SDK | Nothing. On by default |
| Safe automatic retries | SDK | Nothing. Tune with retry if you like |
| Key and secret rotation | Dashboard / API | Rotate on a schedule, confirm when deployed |
| Consent and data protection | API | Capture consent at the moment the user agrees |
Mutual TLS
Every request that uses an API key (X-API-Key + X-Access-Key-Id) must present a client certificate that AttestID issued to your company. It's required for every company and every environment. A stolen API key is useless without your certificate's private key, and that key never leaves your servers.
SDK-token traffic from browsers and phones doesn't need a certificate.
See Client Certificates (mTLS) for how to issue, use and renew a certificate.
Signed verification results
The verified field of a result passes through the user's browser or phone, so it can be tampered with there. That's why POST /v1/kyc/complete and GET /v1/kyc/{verification_id} also return an attestation: an ES256-signed record of the decision, issued by AttestID.
Before you act on a result (open an account, release funds), verify the attestation on your backend:
// your backend (Node 19+, or any runtime with WebCrypto)
import { verifyAttestation, SignatureError } from '@attestid/sdk';
const claims = await verifyAttestation(result.attestation, {
apiUrl: process.env.ATTESTID_API_URL!, // public keys come from /.well-known/jwks.json (cached)
verificationId: expectedVerificationId, // must be the verification you started
companyId: process.env.ATTESTID_COMPANY_ID, // optional
document: result.document, // optional: proves the ID data wasn't altered
});
if (!claims.verified) throw new Error('Not verified');
verifyAttestation() throws SignatureError on a bad signature, an unexpected algorithm (none and HS256 are rejected), the wrong verification or company ID, or document data that doesn't match.
What's in the attestation
| Claim | Meaning |
|---|---|
sub | The verification_id |
company_id, user_id | Who the verification belongs to |
verified, status, failure_reason | The decision |
liveness_passed, face_match_confidence, fraud_flags | The checks behind it |
capture_assurance | How the ID photo was captured (see below) |
document_sha256 | A hash of the document block. The attestation never contains the personal data itself |
created_at, completed_at, iat | Timestamps |
Public keys
AttestID publishes its public signing keys at:
GET https://api.attestid.brimsage.co.ls/.well-known/jwks.json
Pick the key whose kid matches the signature's header. Keys rotate on the server, and retired keys stay listed so older signatures keep verifying. The SDK caches the keys for 10 minutes and refetches when it sees a new kid.
Signed webhooks
Every webhook delivery carries two signatures:
AttestID-Signature: HMAC-SHA256 with your webhook secret.AttestID-JWS: an ES256 signature with AttestID's platform key, verifiable with the same public keys as above. Unlike the shared secret, it proves to a third party that AttestID sent the delivery.
See Webhooks → Verifying the signature.
Device-bound capture signing
In the browser, the SDK creates an ECDSA P-256 key with WebCrypto as non-extractable: scripts, including the SDK itself, can sign with it but can never read or export it. On every upload, the SDK:
- registers the public key with AttestID (
POST /v1/kyc/device/challenge, then/v1/kyc/device/register), and - signs the exact bytes of the ID photo (and the face-check video) together with the user ID and a fresh single-use nonce.
AttestID records the result as the verification's capture_assurance:
capture_assurance | Meaning |
|---|---|
hardware_attested | Signed by a key the device's hardware vouches for (native apps with App Attest / Play Integrity; coming soon) |
key_bound | Signed by a device key registered through the SDK. The best a web browser can reach |
none | Not signed, for example because the browser doesn't support WebCrypto |
invalid | A wrong key, a reused nonce, or altered bytes. The verification is held for fraud review |
This means a photo injected straight into the API, or replayed from an earlier session, is caught.
It's best-effort: if signing isn't possible, the SDK uploads the photo unsigned rather than failing. It's always off in server mode, because a backend process isn't the user's device. To opt out in the browser:
new AttestID({ apiUrl, getSdkToken, deviceBinding: false });
Safe automatic retries
Every POST to /v1/kyc/* can carry an Idempotency-Key header. The SDK sends one automatically and retries failed calls (3 attempts by default, with exponential backoff):
| What happened | What the SDK does |
|---|---|
| Network error | Retries with the same key. If the first attempt actually reached AttestID, you get its stored response back, not a duplicate session or a second billed verification |
409 (still processing) | Waits for Retry-After, then retries with the same key |
5xx / 429 | Retries with a fresh key (nothing was stored) |
Any other 4xx | Throws straight away. Never retried |
new AttestID({ apiUrl, getSdkToken, retry: { attempts: 4, baseDelayMs: 750 } });
If you call the API directly, send one key per logical operation and reuse it on retries. A replayed response has the header Idempotency-Replayed: true. Reusing a key with a different request body returns 422 idempotency_key_reused. Keys are kept for 24 hours, and they're scoped to your credentials, so they can never return another company's or user's response.
Key and secret rotation
All rotations are two-step, so you never have downtime:
| What | Step 1 | Overlap | Step 2 |
|---|---|---|---|
| API key | Dashboard → API Keys → Rotate issues a replacement | Old key works for 24 h | Confirm the rotation to cut the old key off early |
| Webhook secret | POST /v1/webhooks/configure issues a new secret | Deliveries signed with both secrets for 24 h | POST /v1/webhooks/rotation/confirm |
| Client certificate | Issue a new certificate from a fresh CSR | Both valid until you revoke | Revoke the old certificate in the dashboard |
Consent and data protection
AttestID processes biometric data under the Lesotho Data Protection Act 2013.
- Consent timestamp.
POST /v1/kyc/startrequiresconsent_given_at: the moment the user actually agreed (for example, ticked a box and tapped Agree), not when the upload fired. It's rejected if it's more than 24 hours old or more than 30 minutes in the future. AttestID records consent using its own server clock and keeps your timestamp alongside it for investigations. The SDK'sAttestIDVerifiercaptures consent for you. - Processing purpose.
processing_purposemust be one ofidentity_verification(the default),kyc_onboarding,kyc_refresh,account_recoveryorage_verification. Anything else is rejected with422. - Document links.
GET /v1/kyc/{verification_id}/documentsreturns links to the ID photo and selfie that expire after 5 minutes. Ask again for fresh ones. Every call is recorded in the audit log, with apurposequery parameter:verification_review(default),fraud_investigation,dispute_resolutionorcompliance_audit. - Retention. Photos are deleted automatically after the retention period, and you can request erasure of a user's data with
DELETE /v1/account/me/data?user_id=.... - Tamper-evident audit log. AttestID's audit log is hash-chained, and the chain is checked and signed daily, so any edit or deletion can be detected.
Support
For security questions or to report a vulnerability: support@brimsage.co.ls