Client-Side SDK Integrity Attestation
Six client-side checks the SDK runs on itself, scored server-side and stored per session for the SDK integrity tab.
Overview
An adversary-in-the-middle kit such as EvilGinx2 serves your login page through a proxy it controls and injects script into the page. The request that reaches your server carries a valid session, so nothing server-side looks wrong. SDK integrity attestation puts the evidence where the tampering happens: client code runs six checks on itself and on the page it is loaded into, posts the results, and the server scores and stores them.
Where it sits. shield-sdk-attestation verifies and stores, shield_sdk_attestations holds one row per attestation, and Dashboard, Security, SDK integrity reads them back.
Who can use it. The endpoint has no plan gate: any tenant can post attestations with an API key (no particular scope is required) or with a dashboard session, and the per-tenant request quota applies. The dashboard tab is gated to Enterprise.
Where check 3 gets its origins. The allowlist is this platform's own four hosts plus every origin your tenant has declared on its hosted relying-party configuration (shield_rp_configs.allowed_origins), so an attestation from your own domain passes once that configuration exists. Until 2026-09-17 no per-tenant list was supplied at all: every attestation was measured against the four passkeybridge.io hosts alone, so any customer origin failed check 3 on every call, and a proxy signal alongside it was classified aitm rather than evilginx.
If you have declared no origins, the check cannot pass and still reports as failed, because the origin genuinely was not verified. What it no longer does is count as evidence of an attack: a proxy signal with an unverifiable origin classifies as evilginx, not aitm, and threat_details.tls_fingerprint says the tenant has declared no allowed origins rather than implying the origin was rejected. Declare them through the upsert_rp_config action of shield-admin-mutations.
The six checks
All six checks run on every attestation. checks_passed counts how many held, and is_trusted is true only when all six pass and the client clock is within 30 seconds of the server's.
| Check | Fields it reads | Passes when |
|---|---|---|
| Code integrity | code_hash | the value is 64 hex characters, and equals the pinned hash when shield_sdk_manifests holds an active row for that sdk_version |
| DOM injection | dom_clean | true |
| TLS and origin | origin | exactly matches one of the four passkeybridge.io hosts, or an origin your tenant has declared on its relying-party configuration |
| Proxy detection | proxy_headers, fn_tostring_intact | no submitted name matches a known kit signature, and Function.prototype.toString is intact |
| Debugger detection | debugger_clean | true |
| Timing integrity | attestation_ms | more than 5 and at most 5,000 milliseconds |
The kit signatures the server matches are x-evilginx, x-muraena, x-modlishka, x-gophish, x-credsniper and x-king-phisher.
No manifest rows ship with the build, so code integrity is a shape check until an operator adds a row for your SDK version. The clock-skew guard sits outside the six: a skew beyond 30 seconds clears is_trusted and records clock_skew_ms in threat_details without failing a named check.
The client decides dom_clean and debugger_clean before posting. In the module in this repository, dom_clean goes false on a script or iframe whose source names a known kit, an iframe styled to zero opacity, a div[data-phish], a div[data-overlay], a form whose action contains evil, a password input carrying data-capture, or more than two hidden iframes. debugger_clean goes false when a debugger statement stalls for 100 milliseconds or longer, which is what an open breakpoint does.
Threat classification
A failed attestation is stored with is_trusted: false, the six per-check booleans, and at most one threat_class. The classifier returns the first match in this order.
| Threat class | Assigned when |
|---|---|
aitm | proxy detection and the origin check both failed |
evilginx | proxy detection failed while the origin was accepted |
injection | dom_clean was false |
tampering | the code hash was malformed or did not match the pinned manifest |
debugger | a breakpoint stalled the timing probe |
A timing failure maps to no class at all: timing_integrity_ok goes false and is_trusted with it, while threat_class stays null. A clock skew beyond the window behaves the same way. So a row can be untrusted with no threat class, and the dashboard shows exactly that: Untrusted, with the threat column reading None.
threat_details carries the reason for each failed check, for example the rejected origin string, the detected proxy header names, or the measured attestation_ms with the bound it missed and whether it was too fast or too slow.
Building the attestation payload
The payload is ten fields. session_id, sdk_version and origin are required; a request missing any of them answers 400 Missing or invalid attestation payload.
{
"session_id": "32 hex characters, fresh per attestation",
"sdk_version": "1.0.0",
"code_hash": "sha256 over the toString output of five critical functions",
"dom_clean": true,
"origin": "https://app.passkeybridge.io",
"proxy_headers": [],
"fn_tostring_intact": true,
"debugger_clean": true,
"attestation_ms": 34,
"client_timestamp": "2026-09-16T17:00:00.000Z"
}src/lib/sdk-integrity-client.ts in this repository exports runIntegrityChecks(), which returns exactly that object. It is not part of the published @passkeybridge/vc-wallet-sdk package and no shipped page calls it, so you either lift that module into your own bundle or assemble the payload yourself.
session_id is 16 random bytes rendered as hex, generated per run. It is not a device or browser fingerprint and carries nothing about the user. The server hashes the user agent and the origin with SHA-256, and the client IP with keyed HMAC-SHA-256 under the server-held pepper, then stores those digests in place of the values.
Request and response
Two actions on one endpoint, https://api.passkeybridge.io/v1/shield-sdk-attestation. Authenticate with an API key in x-pb-api-key or a dashboard session in Authorization: Bearer. The tenant comes from tenant_id in the body or from the x-pb-tenant-id header. Calls sent straight to the Supabase functions host answer 403 Direct access denied.
curl -X POST https://api.passkeybridge.io/v1/shield-sdk-attestation \
-H "x-pb-api-key: pb_live_..." \
-H "Content-Type: application/json" \
-d '{"action":"attest","tenant_id":"<tenant-uuid>","payload":{"session_id":"a1b2c3d4e5f60718293a4b5c6d7e8f90","sdk_version":"1.0.0","code_hash":"<64 hex>","dom_clean":true,"origin":"https://app.passkeybridge.io","proxy_headers":[],"fn_tostring_intact":true,"debugger_clean":true,"attestation_ms":34,"client_timestamp":"2026-09-16T17:00:00.000Z"}}'The response is a score, and your application decides what to do with it:
{
"id": "uuid",
"is_trusted": false,
"checks_passed": 5,
"checks_total": 6,
"threat_class": "evilginx",
"threat_details": { "tls_fingerprint": "Origin 'https://shop.example.com' could not be verified: this tenant has declared no allowed origins. Add them to the hosted relying party configuration." },
"created_at": "2026-09-16T17:00:00.000Z"
}The list action takes limit (default 50, capped at 200), offset and threat_only, and returns attestations newest first plus a summary of the last 24 hours: total_24h, trusted_24h, threats_24h, trust_rate as a whole-number percentage, and threat_breakdown keyed by class.
| Status | Body or header | Cause |
|---|---|---|
| 400 | Missing tenant_id | no tenant_id in the body or header |
| 400 | Missing or invalid attestation payload | payload absent, or missing session_id, sdk_version or origin |
| 400 | Unknown action | the action is neither attest nor list |
| 401 | x-pb-reason: invalid-key | the key does not match an active key for that tenant |
| 403 | x-pb-reason: edge-locked | the request bypassed api.passkeybridge.io |
| 405 | Method not allowed | any method other than POST |
| 409 | code: REPLAY_DETECTED | an identical payload was already stored within five minutes |
| 413 | Payload too large | the body exceeded 64 KB |
| 429 | x-pb-reason: quota-exceeded | the tenant's per-minute quota is spent |
SDK integrity tab
Dashboard, Security, SDK integrity shows what the endpoint stored. The tab is Enterprise-only and read-only, and it calls the same list action through the public host.
- Four tiles: Attestations in the last 24 hours, Trust rate as a percentage, Trusted, and Threats detected.
- Threats by type appears only when the window holds at least one classified threat, with a count per class.
- Recent attestations lists the 50 newest rows: a Trusted or Untrusted badge, the first 12 characters of the session id, checks passed out of six, the threat label, the attestation duration the client reported, and the timestamp.
- Expanding a row shows all six checks as pass or fail tiles and the raw
threat_detailsobject. - The Threats only switch re-queries with
threat_only: true, and Refresh re-reads the list. Neither polls.
A newly posted attestation appears on the next refresh. If the tab stays empty while your calls answer 200, check that you are viewing the tenant whose tenant_id you posted.
Replay protection
The server computes attestation_hash as SHA-256 over session_id, sdk_version, code_hash, origin and client_timestamp joined with |. When a row with the same hash already exists for that tenant within the last five minutes, the request answers 409 with code: REPLAY_DETECTED and stores nothing.
That binds the guard to the payload as a whole. A fresh run produces a new random session_id and a new client_timestamp, so a genuine client never collides with itself, while a captured payload posted again collides for five minutes and then stops colliding. Stated plainly: this is a duplicate-submission guard rather than a signature. Nothing in the payload is signed, so a client that can produce a plausible payload once can produce a fresh one whenever it likes.
The way to make the code hash carry weight against that is to pin your build: add a row to shield_sdk_manifests with sdk_name, sdk_version and a 64-hex expected_code_hash, and check 1 becomes an exact comparison instead of a shape test. Rows in that table are readable and writable only by a platform admin.
Related from the blog
- Agent Attestation Fatigue: When Too Many Trust Checks Become the Vulnerabilitysecurity · 21 min read
- Anatomy of an A2A Handshake: How Two AI Agents Establish Trust Without a Shared Secretsecurity · 13 min read
- Account Recovery Is the Front Door: Carrier-State Checks in Credit Union and Community Bank Recovery Flowssecurity · 11 min read