DPoP shadow telemetry
What the DPoP shadow log records, how to read adoption, and what enforcement looks like today.
Scope
DPoP shadow mode records whether the requests reaching PasskeyBridge carry an RFC 9449 DPoP proof header, and whether that proof is structurally well formed. Nothing is rejected. The observation is written to shield_dpop_shadow_log from a queued microtask, and a failed write is swallowed, so telemetry can never break a request.
Each row records the function path, the HTTP method, whether a proof was present, whether it was valid, a failure reason when it was not, and a keyed digest of the client IP. The proof is inspected, never cryptographically verified: that is shield-dpop's job. No algorithm column is stored.
The public edge forwards dpop to the functions behind it, so a proof sent to https://api.passkeybridge.io/v1/... arrives intact and is recorded as present.
One limit to know before you start. Observations are written by the shared authentication module, which 16 functions import: shield-dpop, shield-blast, shield-cascade, shield-carrier-lookup, shield-vc-issue, shield-vc-oid4vci, shield-supply-chain, shield-passkey-rp, shield-predict, shield-entropy-receipt, shield-sdk-attestation, shield-provenance-guard, shield-metrics, shield-reattest, shield-breakglass and shield-ingest's siblings. shield-ingest itself authenticates inline and is not observed, so ingest traffic never appears in the adoption numbers.
Shadow mode is on for every organization and needs no switch. For field semantics and the binding API, see DPoP proof of possession; for the reasoning, see the DPoP token binding article.
Prerequisites
- An organization with an API key (Core > API keys), and the
dpopscope on it if you intend to callshield-dpopitself. - Admin of that organization. The adoption query is a SECURITY DEFINER function that re-checks
is_tenant_adminand raisesNot authorizedotherwise. - A client that calls one of the observed functions listed above.
You do not need to enable anything. The work below is producing proofs on the client and confirming what lands.
Step 1 · Generate a non-extractable client key
In a browser, generate an ES256 key pair with the Web Crypto API and keep the private key non-extractable.
const { publicKey, privateKey } = await crypto.subtle.generateKey(
{ name: "ECDSA", namedCurve: "P-256" },
/* extractable */ false,
["sign", "verify"],
);
// Persist the public JWK and the private CryptoKey in IndexedDB:
// one key pair per device, reused across sessions.On iOS and Android use a Secure Enclave or Keystore key. For a confidential client, treat the private key with the same care as a client secret.
shield-dpop accepts ES256, ES384, ES512, PS256, PS384 and PS512 when it verifies a proof. ES256 is the interoperable choice.
Step 2 · Sign a DPoP proof on every request
Build the proof JWT with the claims RFC 9449 section 4 requires:
| Field | Value |
|---|---|
typ (header) | "dpop+jwt" exactly |
alg (header) | "ES256" |
jwk (header) | Your public JWK, with no private key material |
htm | The request method |
htu | The request URI without query or fragment |
iat | Current Unix time |
jti | A fresh UUID per proof |
ath | base64url(SHA-256(access_token)), required: every bind and verify presents a token |
Attach it as the DPoP header:
POST /v1/shield-dpop HTTP/1.1
Host: api.passkeybridge.io
Content-Type: application/json
x-pb-api-key: pb_live_YOUR_KEY
DPoP: <signed-dpop-jwt>Most OAuth client libraries can produce this for you (oauth4webapi, AppAuth, MSAL).
The shadow recorder marks a proof valid when it is three dot-separated segments under 8 KB, its header carries typ: "dpop+jwt", a string alg and a jwk object, and its payload carries string htm and htu and a numeric iat. Anything else is recorded with one of these reasons: proof_too_large, malformed_jwt, wrong_typ, missing_alg, missing_jwk, missing_htm_htu, missing_iat, parse_error.
The header survives the trip through api.passkeybridge.io, so a well-formed proof lands as dpop_present: true with dpop_valid: true on the next adoption read.
Step 3 · Read the adoption numbers
Adoption is read with the shield_get_dpop_adoption function, from a client signed in as an admin of the organization. It is a database function, not an HTTP endpoint at the public host.
import { createClient } from "@supabase/supabase-js";
const supabase = createClient(
"https://YOUR_PROJECT.supabase.co",
"YOUR_PUBLISHABLE_KEY",
);
// Sign in first: the function re-checks that you administer this organization.
const { data, error } = await supabase.rpc("shield_get_dpop_adoption", {
_tenant_id: "YOUR_ORGANIZATION_ID",
_hours: 1,
});_hours defaults to 24. The result:
{
"window_hours": 1,
"total_requests": 42,
"with_dpop": 12,
"valid_dpop": 12,
"adoption_pct": 28.57,
"validity_pct": 100,
"by_endpoint": [
{ "endpoint": "/shield-dpop", "total": 30, "with_dpop": 12, "valid_dpop": 12 }
]
}by_endpoint holds at most 20 entries, ordered by volume. adoption_pct is with_dpop over total_requests; validity_pct is valid_dpop over with_dpop, so it reads 0 while nothing carries a proof.
Reading it honestly. total_requests counts only requests that went through the shared authentication module, so it is not your whole traffic. A non-admin caller gets an insufficient_privilege error rather than an empty result, which is worth distinguishing in your own error handling.
Step 4 · Enforcing a proof today
There is no enforcement switch and no WWW-Authenticate: DPoP challenge. Enforcement is something your own gateway does by calling shield-dpop before it honors a request, and acting on the answer.
curl -X POST https://api.passkeybridge.io/v1/shield-dpop -H "Content-Type: application/json" -H "x-pb-api-key: pb_live_YOUR_KEY" -d '{
"action": "verify",
"tenant_id": "YOUR_ORGANIZATION_ID",
"dpop_proof": "eyJ0eXAiOiJkcG9wK2p3dCIsLi4ufQ...",
"access_token": "the token the client presented",
"htm": "POST",
"htu": "https://your-api.example.com/transfer"
}'A proof must first have been bound with {"action": "bind", ...}, which stores the thumbprint against the access-token digest for 5 minutes. The dpop scope is required on the key for bind, verify and nonce.
Success (200):
{ "status": "verified", "jkt": "NzbLsXh8...", "binding_id": "...", "token_bound": true }Failures, as JSON rather than as a challenge header:
| Status | Body | Meaning |
|---|---|---|
| 400 | DPoP htm mismatch or DPoP htu mismatch | The proof was minted for a different method or URI. htu is compared as scheme, authority and path |
| 400 | DPoP proof iat out of acceptable window | More than 60 s in the future or more than 5 minutes old |
| 400 | code: "DPOP_NONCE_REQUIRED" with a fresh dpop_nonce | The binding carries a nonce and the proof did not match it. Retry once with the nonce returned |
| 401 | DPoP signature verification failed | The signature did not verify against the embedded JWK |
| 401 | code: "DPOP_REPLAY" | The binding was already consumed. Bindings are one-time by design |
| 403 | code: "DPOP_BINDING_NOT_FOUND" | No live, unconsumed binding for this token and key pair |
| 409 | Duplicate DPoP proof jti (replay detected) | On bind, that jti was already seen for this organization |
The introspect and cleanup actions take an organization session instead of a key: introspect lists recent bindings for a thumbprint to a member, and cleanup deletes expired bindings for an admin.
Troubleshooting
An observation records the proof as absent. The DPoP header did not arrive on that request. Check that your client sets it on the call it makes to PasskeyBridge rather than only on calls to your own API, that the header name is DPoP, and that the request went to one of the observed functions listed above.
`adoption_pct` is 0 and `total_requests` is 0. You are calling a function that does not use the shared authentication module. shield-ingest is the common case: it authenticates inline and writes no shadow row. Point a test client at shield-dpop or another observed function instead.
`failure_reason: "wrong_typ"`. The header typ must be exactly "dpop+jwt", lowercase.
`failure_reason: "missing_alg"` or `"missing_jwk"`. The proof header omitted the algorithm or the public JWK. The JWK must be the public key only; shield-dpop refuses a proof carrying private key material.
`failure_reason: "missing_htm_htu"`. The payload is missing htm or htu. Both must be strings.
`failure_reason: "parse_error"` or `"malformed_jwt"`. The header is not three base64url segments of parseable JSON. Check that the encoding is base64url without padding.
`Not authorized` from the adoption query. You are signed in as a member rather than an admin of that organization, or against a different organization id.