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.

Last reviewed September 17, 2026Fresh

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.

CheckFields it readsPasses when
Code integritycode_hashthe value is 64 hex characters, and equals the pinned hash when shield_sdk_manifests holds an active row for that sdk_version
DOM injectiondom_cleantrue
TLS and originoriginexactly matches one of the four passkeybridge.io hosts, or an origin your tenant has declared on its relying-party configuration
Proxy detectionproxy_headers, fn_tostring_intactno submitted name matches a known kit signature, and Function.prototype.toString is intact
Debugger detectiondebugger_cleantrue
Timing integrityattestation_msmore 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 classAssigned when
aitmproxy detection and the origin check both failed
evilginxproxy detection failed while the origin was accepted
injectiondom_clean was false
tamperingthe code hash was malformed or did not match the pinned manifest
debuggera 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.

StatusBody or headerCause
400Missing tenant_idno tenant_id in the body or header
400Missing or invalid attestation payloadpayload absent, or missing session_id, sdk_version or origin
400Unknown actionthe action is neither attest nor list
401x-pb-reason: invalid-keythe key does not match an active key for that tenant
403x-pb-reason: edge-lockedthe request bypassed api.passkeybridge.io
405Method not allowedany method other than POST
409code: REPLAY_DETECTEDan identical payload was already stored within five minutes
413Payload too largethe body exceeded 64 KB
429x-pb-reason: quota-exceededthe 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_details object.
  • 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