Skip to main content

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.

ProtectionWhere it runsWhat you need to do
Mutual TLSYour backend → AttestIDRequired. Present a client certificate on every API-key request
Signed verification resultsYour backendVerify result.attestation before acting on verified
Signed webhooksYour webhook receiverVerify AttestID-Signature (and optionally AttestID-JWS)
Device-bound capture signingBrowser SDKNothing. On by default
Safe automatic retriesSDKNothing. Tune with retry if you like
Key and secret rotationDashboard / APIRotate on a schedule, confirm when deployed
Consent and data protectionAPICapture 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​

ClaimMeaning
subThe verification_id
company_id, user_idWho the verification belongs to
verified, status, failure_reasonThe decision
liveness_passed, face_match_confidence, fraud_flagsThe checks behind it
capture_assuranceHow the ID photo was captured (see below)
document_sha256A hash of the document block. The attestation never contains the personal data itself
created_at, completed_at, iatTimestamps

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:

  1. registers the public key with AttestID (POST /v1/kyc/device/challenge, then /v1/kyc/device/register), and
  2. 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_assuranceMeaning
hardware_attestedSigned by a key the device's hardware vouches for (native apps with App Attest / Play Integrity; coming soon)
key_boundSigned by a device key registered through the SDK. The best a web browser can reach
noneNot signed, for example because the browser doesn't support WebCrypto
invalidA 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 happenedWhat the SDK does
Network errorRetries 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 / 429Retries with a fresh key (nothing was stored)
Any other 4xxThrows 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:

WhatStep 1OverlapStep 2
API keyDashboard → API Keys → Rotate issues a replacementOld key works for 24 hConfirm the rotation to cut the old key off early
Webhook secretPOST /v1/webhooks/configure issues a new secretDeliveries signed with both secrets for 24 hPOST /v1/webhooks/rotation/confirm
Client certificateIssue a new certificate from a fresh CSRBoth valid until you revokeRevoke the old certificate in the dashboard

AttestID processes biometric data under the Lesotho Data Protection Act 2013.

  • Consent timestamp. POST /v1/kyc/start requires consent_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's AttestIDVerifier captures consent for you.
  • Processing purpose. processing_purpose must be one of identity_verification (the default), kyc_onboarding, kyc_refresh, account_recovery or age_verification. Anything else is rejected with 422.
  • Document links. GET /v1/kyc/{verification_id}/documents returns 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 a purpose query parameter: verification_review (default), fraud_investigation, dispute_resolution or compliance_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