Integrations: SSO, SCIM, Event Hooks and webhooks
How identity and security tooling connects to PasskeyBridge: SCIM 2.0 provisioning, Okta Event Hooks, universal signal ingest, outbound webhook delivery, and where SSO actually lives.
Integration surface
PasskeyBridge takes identity signals in and runs playbook actions out. Your identity provider still authenticates people; PasskeyBridge reacts to what it reports, digests every identifier it touches, and fires the actions you configured.
These are the inbound surfaces and the authentication lane each one uses. They do not share a lane, so check yours before writing any integration code.
| Surface | Function | Routed at api.passkeybridge.io | Authentication |
|---|---|---|---|
| Signal ingest | shield-ingest | Yes | x-pb-api-key with the ingest scope |
| SCIM 2.0 provisioning | shield-scim | Yes | Authorization: Bearer {api key} with the scim scope |
| Okta Event Hooks | shield-okta-hooks | Yes | Authorization: Bearer {api key} with okta_hooks or ingest, plus x-pb-tenant-id |
| Shared Signals and CAEP | shield-sse-caep | Yes | Dashboard session JWT to manage streams; a signed token to deliver one |
| SAML SSO setup | shield-sso-provision, shield-sso-verify-domain, shield-sso | No | Dashboard session only, invoked from the SSO tab |
Outbound there is one surface: a playbook webhook_callback action POSTs a JSON envelope to your endpoint. Slack messages and alert emails are separate playbook actions, described in Playbooks.
Only the function names on the edge proxy allowlist answer on https://api.passkeybridge.io/v1/{function}. Anything else returns 404 with x-pb-reason: unknown-function. Console-only functions have no public URL at all; the dashboard invokes them with your signed-in session.
Every request is tenant-scoped and written to shield_audit_log. Identifiers are digested with keyed HMAC-SHA-256 under a server-held pepper before anything stores them.
Single sign-on
Two different things carry the name SSO in this product, and only one of them federates logins.
Self-serve SAML is the working path. A tenant admin registers a SAML provider, proves ownership of an email domain by DNS TXT record or an emailed token, then activates the provider, which registers it with the auth backend. End-user sign-in completes: the /auth page looks the domain up through shield-sso-lookup, which calls its database function with the service role, so a domain verified on an active provider answers has_sso: true and the user is redirected to their identity provider. An unknown domain answers has_sso: false. EXECUTE on that database function stays revoked from anon, so a browser never calls it directly. The walkthrough is Self-serve SAML SSO setup.
The legacy connector stores configuration and routes nothing. shield-sso writes SAML or OIDC field values to shield_sso_configs and sets sso_enabled on the tenant row. No sign-in path reads those rows, so a config saved here federates no logins. It appears in Dashboard > SSO under "Legacy SAML and OIDC configuration", and its form is open to Enterprise tenants only.
shield-sso is absent from the edge proxy allowlist, so POST /v1/shield-sso answers 404. The dashboard calls it with your session JWT. Its actions are list (any tenant member), and create, update, delete and get_metadata (tenant admin). Client secrets are encrypted with AES-256-GCM before storage, every mutation writes an audit row, and deleting the last config sets sso_enabled back to false.
SCIM 2.0 provisioning
shield-scim implements SCIM 2.0 (RFC 7644) for Users and Groups.
Base URL: https://api.passkeybridge.io/v1/shield-scim
Authentication: Authorization: Bearer {api key}, using a key minted with the scim scope from Dashboard > API keys > "Generate API key".
| Path | Methods |
|---|---|
/Users | GET, POST |
/Users/:id | GET, PUT, PATCH, DELETE |
/Groups | GET, POST |
/Groups/:id | GET, PATCH, DELETE |
/ServiceProviderConfig | GET |
/Schemas | GET |
/ResourceTypes | GET |
The rule that decides whether your rollout works: PasskeyBridge resolves accounts, it never creates them. userName must be the email address of an existing PasskeyBridge account. When nothing matches, the response is 400 with SCIM type invalidValue and the detail "userName does not match a PasskeyBridge account. The user must sign up first, then provisioning can link them (same rule as dashboard invites)."
Stored per user: the resolved account id, externalId, active (as scim_active) and displayName on the profile row. name.givenName, name.familyName and emails are accepted and echoed back, and no column stores them.
Groups are the three tenant roles, addressed as {tenant_id}:admin, {tenant_id}:member and {tenant_id}:viewer. A membership PATCH carries the SCIM user id in members[].value; an email of a user who has already signed up is resolved the same way userName is, and anything else answers 400.
Responses use application/scim+json. Every operation writes a row to shield_scim_provisioning_log holding keyed digests of the identifiers and never the raw body; Enterprise tenants read it in Dashboard > Settings > SCIM provisioning.
Field-by-field setup, the error strings and a test plan: Okta SCIM provisioning.
Okta Event Hooks
shield-okta-hooks accepts Okta Event Hook deliveries and turns them into PasskeyBridge events and revocations.
Endpoint: https://api.passkeybridge.io/v1/shield-okta-hooks
Headers on every delivery:
Authorization: Bearer {api key}, with theokta_hooksoringestscopex-pb-tenant-id, your tenant id
During hook registration Okta sends a GET carrying x-okta-verification-challenge. The function echoes the value back, so there is no manual verification step:
{ "verification": "the challenge value" }Handled event types and what each one does:
| Okta event | Effect | Event row written |
|---|---|---|
user.lifecycle.suspend | Agent delegates deactivated, cached proofs invalidated | okta_user_suspended |
user.lifecycle.deactivate | Tenant membership deleted, delegates deactivated, shadow identities revoked | okta_user_deactivated |
user.lifecycle.unsuspend | Logged; delegates stay deactivated until you re-approve them | okta_user_unsuspended |
user.session.start | Stored as a behavioral signal with a keyed digest of the source IP | okta_session_start |
user.account.update_password | Stored with risk_score 0.6 | okta_password_changed |
user.mfa.factor.deactivate | Stored with risk_score 0.8 | okta_mfa_deactivated |
Any other event type is stored under okta_ plus the event type with dots replaced by underscores, with result logged. Every processed event also writes shield_audit_log with actor_type: "system" and action: "okta.hook.{eventType}".
The revocations resolve through SCIM, and say what they did. Since 2026-09-17 the suspend and deactivate paths look the Okta target id up in shield_tenant_members.external_id and match rows by the PasskeyBridge user id it returns, with user_hash matched against that id's keyed digests. Before that they compared the Okta id directly against delegated_by, user_hash and user_id, which revoked nothing unless the ids happened to coincide, and reported success anyway. Each event now records the rows it actually changed and one of agents_revoked, fully_deprovisioned, nothing_to_revoke, unmapped_okta_user, member_lookup_failed or partial_failure. If you see unmapped_okta_user, SCIM has no externalId for that person yet.
Limits: at most 50 events per delivery are processed, and the response reports total_received and truncated so you can see when a batch was cut; an in-memory limit of 30 requests per minute per source IP sits in front of the function. A processed delivery answers 200 with a per-event results array.
x-pb-test-mode has no effect here. Only shield-ingest reads it.
Universal signal ingest
Any HTTP client can post a signal. No SDK is required.
curl -X POST https://api.passkeybridge.io/v1/shield-ingest \
-H "x-pb-api-key: pb_live_..." \
-H "x-pb-tenant-id: 8f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f" \
-H "content-type: application/json" \
-d '{
"event_type": "sim_swap",
"phone": "+14155550100",
"source_id": "2c9a7b10-5d31-4f88-9a0e-7b6c5d4e3f21",
"subject_ref": "user_8812",
"metadata": { "carrier": "tmobile", "swap_type": "port_out" }
}'The tenant id travels in x-pb-tenant-id or as a path segment; without it the answer is 400. Send phone and PasskeyBridge computes the keyed digest for you, or send phone_hash as 64 lowercase hex characters to hash it yourself, in which case the row records hash version 1 to note that the digest is unkeyed. event_type is any string matching [a-zA-Z0-9_-]+. subject_ref is your own opaque handle, up to 128 printable ASCII characters, refused when it looks like an email address or a phone number.
A successful call answers 200:
{
"status": "ok",
"result": "playbook_executed",
"actions_count": 2,
"actions_failed_count": 0,
"latency_ms": 214,
"correlation_id": "c-9f2c41a8"
}status is warning when any action failed. result is no_matching_playbook when nothing matched the event type. hibp_compromised and hibp_breach_count are added when you sent credential_sha1.
Nine hard signal types revoke agent delegates and A2A negotiations inline, before any playbook action runs; soft types queue a debounced trust evaluation. The vocabulary and the classifier are described in Identity signals.
Rate limits apply per tenant and per source IP, resolved from your plan with an optional per-tenant override, and Cloudflare holds a ceiling of 1,200 requests per minute per source IP in front of the function. The figures are in Rate limits and quotas.
Outbound webhook delivery
When a playbook's webhook_callback action fires, PasskeyBridge POSTs a JSON envelope to the action's URL, or to the tenant callback URL when the action carries none.
{
"event": "sim_swap",
"phone_hash": "a1b2c3d4e5f6...",
"subject_ref": "user_8812",
"tenant_id": "8f1c2d3e-4a5b-6c7d-8e9f-0a1b2c3d4e5f",
"timestamp": "2026-09-16T14:30:00.000Z",
"metadata": { "carrier": "tmobile", "risk_score": 0.92 }
}metadata is the body you sent at ingest with raw identifiers removed and subject_ref lifted to the top level.
One attempt is made inline inside your ingest request, with a five-second deadline. A 5xx, a 429, a network error or that deadline hands the delivery to a queue where shield-webhook-worker makes attempts 2, 3 and 4. Any other 4xx is terminal and is never queued. The worker runs every five minutes, so a retried delivery arrives minutes later, and only those worker attempts carry the detached ML-DSA headers; the inline attempt carries the HMAC alone.
x-pb-signature holds a lowercase hex HMAC-SHA-256 of the exact request body under the tenant's callback signing secret, and is absent when no secret is configured. Treat an unsigned delivery as unauthenticated.
The envelope carries no delivery id, and timestamp is stamped once per delivery, so every retry repeats it. Deduplicate on the combination of tenant_id, event, phone_hash and timestamp.
Deliveries and their responses are listed in Dashboard > Webhooks. Verification code, the retry table and the delivery-log fields are in Webhook configuration.
Signal sources and destinations
PasskeyBridge carries no per-vendor connector code. Everything inbound arrives through one of the surfaces above, and everything outbound leaves as a playbook action.
| Source | How it reaches PasskeyBridge |
|---|---|
| Twilio, Vonage and other carrier or fraud APIs | Your service calls them and posts the result to shield-ingest |
| Okta | SCIM 2.0 for provisioning, Event Hooks for lifecycle events, SAML for sign-in |
| Microsoft Entra ID, Google Workspace, OneLogin, Auth0 and other SAML 2.0 providers | Self-serve SAML for sign-in |
| Any Shared Signals transmitter | Signed Security Event Tokens to shield-sse-caep |
| Destination | How PasskeyBridge reaches it |
|---|---|
| Your own backend | webhook_callback playbook action |
| Slack | slack_webhook playbook action, posting to a registered incoming webhook |
email_alert playbook action, addressed to the admins or all members of the tenant | |
| A CAEP receiver | Signed tokens pushed by shield-sse-deliver |
PagerDuty, OpsGenie, Datadog and similar tools have no dedicated action type. They receive the same generic webhook_callback envelope shown above and parse it themselves.
Passkey registration and authentication are served by the passkey function, and the hosted relying-party API by shield-passkey-rp. See Passkeys.
Okta Integration Network status
PasskeyBridge is preparing a submission to the Okta Integration Network and has no published OIN listing today. Add it to your Okta org as a custom SCIM 2.0 application, using the base URL and bearer token above.
The assets a submission needs exist in the repository: the SCIM endpoint (shield-scim), the Event Hook consumer (shield-okta-hooks), an OIN manifest at docs/okta-oin-manifest.json and an OpenAPI document at docs/openapi.yaml. Treat the manifest as a submission draft rather than evidence of a listing.
Two claims that travelled with earlier drafts of this page are wrong and worth correcting here. There is no sandbox mode on the SCIM or Event Hook endpoints: x-pb-test-mode is read only by shield-ingest, and only with a tenant-admin session. And identifiers are digested with keyed HMAC-SHA-256 under a server-held pepper, which is what makes a small keyspace such as a phone number non-enumerable; plain SHA-256 would not.
Questions about the listing go to partnerships@passkeybridge.io.
Setup walkthrough
1. Mint an API key. Dashboard > API keys > "Generate API key". Give it a label, tick the scopes it needs, and turn on Sandbox if you want unmetered test traffic. Production keys start pb_live_ and sandbox keys start pb_test_. The key is shown once. The scopes used here are ingest for signals, scim for provisioning, okta_hooks for Event Hooks and events to read events back; the full list is in Scopes and permissions.
2. Point your signal source at ingest. Send one signal with the curl above and confirm it lands in Dashboard > Events.
3. Connect SCIM, if you use it. In Okta, add a custom SCIM 2.0 app. Set the connector base URL to https://api.passkeybridge.io/v1/shield-scim, set the unique identifier field to userName, set Authentication Mode to HTTP Header, and paste Bearer followed by your scim key. Enable Create Users, Update User Attributes and Deactivate Users. Everyone you assign must already have a PasskeyBridge account under the same email address.
4. Connect Event Hooks, if you use them. In Okta, create an Event Hook pointing at https://api.passkeybridge.io/v1/shield-okta-hooks, add an Authorization header holding Bearer and your okta_hooks key, add x-pb-tenant-id with your tenant id, subscribe to the events you want, and click Verify.
5. Set up SSO, if you need it. Dashboard > SSO > "Add provider", then verify a domain and activate. Follow Self-serve SAML SSO setup, which has the button labels, the verification methods and the sign-in path end to end.
6. Write a playbook. Dashboard > Playbooks > "New playbook" sets the trigger event type and the actions. A Webhook Callback action falls back to the URL in Dashboard > Settings > Callback URL, and signs its delivery only when a secret exists under Dashboard > Settings > Callback signing secret.
Related from the blog
- Quantum Key Distribution and Identity Systems: Separating the Signal from the Hypesecurity · 18 min read
- Running CAEP in Production: What Signing Outbound SETs Taught Us About Receiverssecurity · 13 min read
- RCS Business Messaging as an Identity Channel: Verified Sender Telemetry Beyond A2P SMSintel · 16 min read