DPoP shadow telemetry

What the DPoP shadow log records, how to read adoption, and what enforcement looks like today.

Last reviewed September 18, 2026Fresh

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 dpop scope on it if you intend to call shield-dpop itself.
  • Admin of that organization. The adoption query is a SECURITY DEFINER function that re-checks is_tenant_admin and raises Not authorized otherwise.
  • 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:

FieldValue
typ (header)"dpop+jwt" exactly
alg (header)"ES256"
jwk (header)Your public JWK, with no private key material
htmThe request method
htuThe request URI without query or fragment
iatCurrent Unix time
jtiA fresh UUID per proof
athbase64url(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:

StatusBodyMeaning
400DPoP htm mismatch or DPoP htu mismatchThe proof was minted for a different method or URI. htu is compared as scheme, authority and path
400DPoP proof iat out of acceptable windowMore than 60 s in the future or more than 5 minutes old
400code: "DPOP_NONCE_REQUIRED" with a fresh dpop_nonceThe binding carries a nonce and the proof did not match it. Retry once with the nonce returned
401DPoP signature verification failedThe signature did not verify against the embedded JWK
401code: "DPOP_REPLAY"The binding was already consumed. Bindings are one-time by design
403code: "DPOP_BINDING_NOT_FOUND"No live, unconsumed binding for this token and key pair
409Duplicate 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.

Related from the blog