Okta SCIM Provisioning
Configure SCIM 2.0 provisioning and Okta Event Hooks against PasskeyBridge: connection settings, what each attribute does, group-to-role push, the provisioning log, and the account rule that decides whether it works.
Before you start
This guide covers SCIM 2.0 provisioning and Okta Event Hooks between one Okta org and one PasskeyBridge tenant.
Read this before planning the rollout: SCIM links accounts that already exist. PasskeyBridge never creates a login from a SCIM request, because creating one would also provision that person a tenant of their own. Everyone you intend to provision must have signed up at PasskeyBridge under the same email address Okta sends as userName. Assigning anyone else returns 400 and creates nothing.
Once that holds, here is what the integration does:
| Capability | Effect in PasskeyBridge |
|---|---|
| User provisioning | The existing account is added to the tenant as a member, with scim_active true |
| Attribute updates | displayName updates the profile; externalId and active update the membership |
| Deprovisioning | scim_active is set to false on the membership row |
| Group push | The member's role becomes admin, member or viewer |
| Event Hooks | Lifecycle events revoke agent delegates and cached proofs and write event rows |
You need:
- A PasskeyBridge tenant and a tenant-admin account on it
- An Okta org with administrative access
- An API key carrying the
scimscope, and a second carryingokta_hooksif you use Event Hooks, from Dashboard > API keys > "Generate API key" - The Enterprise plan to read the provisioning log in the dashboard; the SCIM endpoint itself carries no plan gate
Connect SCIM in Okta
PasskeyBridge has no Okta Integration Network listing yet, so add it as a custom SCIM 2.0 application.
- In the Okta Admin Console, create a new app integration and choose SCIM 2.0 provisioning.
- Fill in the connection settings.
| Field | Value |
|---|---|
| SCIM connector base URL | https://api.passkeybridge.io/v1/shield-scim |
| Unique identifier field for users | userName |
| Authentication Mode | HTTP Header |
| Authorization | Bearer followed by your scim-scoped API key |
- Test the connection. Okta calls
GET /ServiceProviderConfig, which needs no authentication and answers 200 withapplication/scim+json. The document advertises PATCH supported, bulk unsupported, filtering supported with a maximum of 200 results, and OAuth bearer-token authentication. - Under Provisioning > To App, enable Create Users, Update User Attributes and Deactivate Users.
To check the token itself rather than the endpoint, list users directly:
curl -s https://api.passkeybridge.io/v1/shield-scim/Users \
-H "Authorization: Bearer pb_live_..."A valid key answers 200 with a SCIM ListResponse. An unknown or inactive key answers 401 "Invalid SCIM bearer token", and a key that exists without the scope answers 403 "API key does not have 'scim' scope".
Attribute mapping
Okta sends a full SCIM user. PasskeyBridge keeps four things from it.
| SCIM attribute | Okta source | Stored as | Notes |
|---|---|---|---|
userName | user.login | Resolved to the PasskeyBridge account id on shield_tenant_members.user_id | Must match an existing account's email address |
externalId | user.id | shield_tenant_members.external_id | Unique within the tenant; a duplicate answers 409 |
displayName | user.displayName | profiles.display_name | Falls back to name.formatted, then to userName |
active | computed by Okta | shield_tenant_members.scim_active | False means deprovisioned |
name.givenName, name.familyName and emails are accepted, validated as part of the schema and echoed in responses. No column stores them, so mapping them in Okta is harmless and changes nothing on this side.
A new membership always gets the role member. Roles move through group push, covered next.
Reading users back accepts filter=userName eq "person@example.com" or filter=externalId eq "00u1a2b3c4", with startIndex and count for paging and a maximum page size of 200. A userName filter naming an address with no PasskeyBridge account answers 200 with totalResults 0, because the address never reaches the database as a lookup value.
Group push and role mapping
PasskeyBridge exposes exactly three groups, one per tenant role. They always exist, and they cannot be created or deleted.
| Group id | Role granted |
|---|---|
{tenant_id}:admin | Full tenant management |
{tenant_id}:member | Dashboard access and event visibility |
{tenant_id}:viewer | Read-only dashboard access |
POST /Groups answers 201 with the current membership of the role named in displayName, creating nothing. DELETE /Groups/:id answers 204 and changes nothing.
`members[].value` is the SCIM user id, the PasskeyBridge account id returned as id on every User resource. An email address is accepted too and resolved against existing accounts, the same rule userName follows on user provisioning. A value that matches no account answers 400 invalidValue naming the problem, and a membership write that fails answers 500, both with an error row in the provisioning log. Until 2026-09-17 an unresolvable value produced a type error that went only to the server log while the request answered 200 with the group unchanged and the log row said success, so a group push could appear to work while roles never moved.
{
"schemas": ["urn:ietf:params:scim:api:messages:2.0:PatchOp"],
"Operations": [
{
"op": "add",
"path": "members",
"value": [{ "value": "3f9a2c64-1d0b-4a7e-9c31-08b2f1d4e5a6" }]
}
]
}Adding someone who is not yet on the tenant creates the membership with that role and scim_active true. Removing them uses "op": "remove" with a path of members[value eq "{user id}"], which sets scim_active false and keeps the row.
Supported PATCH operations are add, remove and replace. Anything else answers 400 with SCIM type invalidValue and the detail "Invalid PATCH op: {op}". A user pushed through two role groups ends up with whichever PATCH arrived last, so map each person to exactly one role group.
Okta Event Hooks
Event Hooks push Okta lifecycle events into PasskeyBridge in real time, which is what lets a suspension in Okta revoke agent delegates here.
- In the Okta Admin Console, go to Workflow > Event Hooks and create a hook.
- Set the URL to
https://api.passkeybridge.io/v1/shield-okta-hooks. - Add an
Authorizationheader holdingBearerand an API key with theokta_hooksoringestscope. - Add a second header,
x-pb-tenant-id, holding your tenant id. Without it the delivery answers 400. - Subscribe to the events you want and click Verify. Okta sends a GET with
x-okta-verification-challenge; the function echoes the value back automatically.
| Okta event | Effect |
|---|---|
user.lifecycle.suspend | Agent delegates deactivated with reason okta:user.lifecycle.suspend, cached proofs invalidated, okta_user_suspended event written |
user.lifecycle.deactivate | Tenant membership deleted, delegates deactivated, shadow identities revoked, okta_user_deactivated event written with result fully_deprovisioned |
user.lifecycle.unsuspend | okta_user_unsuspended event written; delegates stay off until you re-approve them |
user.session.start | okta_session_start event written, with a keyed digest of the source IP |
user.account.update_password | okta_password_changed event written with risk_score 0.6 |
user.mfa.factor.deactivate | okta_mfa_deactivated event written with risk_score 0.8 |
Every other event type is still stored, under okta_ plus the event type with dots replaced by underscores, with result logged. Each processed event writes an audit row with actor_type: "system" and action: "okta.hook.{eventType}".
The revocations resolve the Okta id through SCIM. The suspend and deactivate paths look the event target up in shield_tenant_members.external_id and act on the PasskeyBridge user id it returns; user_hash columns are matched against that id's keyed digests. Until 2026-09-17 the Okta id was compared directly against delegated_by, user_hash and user_id, so unless the ids coincided nothing was revoked and the response said fully_deprovisioned regardless. The stored event now carries the affected row counts, and the result is one of agents_revoked, fully_deprovisioned, nothing_to_revoke, unmapped_okta_user, member_lookup_failed or partial_failure. A user SCIM has never sent an externalId for reads unmapped_okta_user rather than succeeding quietly.
At most 50 events are processed per delivery; the response reports total_received and truncated so a cut batch is visible. Deliveries answer 200 with a per-event results array, even when individual writes failed, so treat the results entries as the record of what happened.
Provisioning log
Every SCIM operation writes one row to shield_scim_provisioning_log, whether it succeeded or failed.
| Field | Contents |
|---|---|
operation | user.create, user.replace, user.patch, user.delete, user.deactivate, group.add_member, group.remove_member or group.create |
resource_type | User or Group |
resource_id | The PasskeyBridge account id, or the group id |
external_id | The Okta externalId, when the request carried one |
scim_payload | active, role and operations_count in the clear, plus keyed HMAC-SHA-256 digests of userName, externalId, displayName and any member value |
result | success or error |
error_detail | The failure string, for example no_account_for_userName or User already exists |
api_key_id | Which key made the request, which separates Okta traffic from manual calls |
ip_hash | Keyed digest of the source IP |
user_agent | First 512 characters of the header |
latency_ms | Server-side processing time |
The raw request body is never stored. When the hashing pepper is unavailable the row is skipped rather than written in the clear.
Enterprise tenants read the log in Dashboard > Settings > SCIM provisioning. It shows the 200 most recent rows, filterable by operation and by result, with totals and an average latency above the table, and each row expands to show the error detail, the IP digest, the user agent and the redacted payload. There is no API for reading it back.
The failures you are most likely to meet:
| Detail | Status | SCIM type | Cause |
|---|---|---|---|
| userName does not match a PasskeyBridge account | 400 | invalidValue | The person has never signed up for PasskeyBridge |
| userName is required | 400 | invalidValue | Okta mapped nothing into userName |
| Invalid PATCH op | 400 | invalidValue | Only add, remove and replace are supported |
| Request body is not valid JSON | 400 | invalidValue | Malformed payload |
| User already exists | 409 | uniqueness | That account is already a member of this tenant |
| externalId already exists | 409 | uniqueness | Another membership already carries that externalId |
| Invalid SCIM bearer token | 401 | none | Unknown or inactive key |
| API key does not have 'scim' scope | 403 | none | The key exists and the scope is missing |
Testing your integration
There is no sandbox mode on this endpoint. x-pb-test-mode is read only by shield-ingest, and only with a tenant-admin session, so every SCIM call here is a real one against your tenant. Test with an account you control.
- Connectivity. Run the Okta connection test, or curl
/ServiceProviderConfig. Expect 200 andapplication/scim+json. - Provision. Have your test person sign up at PasskeyBridge first, then assign them to the app in Okta. Confirm the membership in Dashboard > Team and a
user.createrow with resultsuccessin Dashboard > Settings > SCIM provisioning. - Attribute update. Change their display name in Okta. Expect a
user.patchoruser.replacerow, and the new name on the member. - Group push. Push them into the group mapped to
admin. Expect agroup.add_memberrow and the role changing in Dashboard > Team. - Deprovision. Unassign them in Okta. Expect a
user.deactivaterow andscim_activefalse. Dashboard access does not end here. Deactivation marks the membership row and keeps it, so remove the person in Dashboard > Team, or send theuser.lifecycle.deactivateEvent Hook, which deletes the membership outright. - Event Hooks. Suspend them in Okta and look for an
okta_user_suspendedrow in Dashboard > Events, anokta.hook.user.lifecycle.suspendentry in Dashboard > Audit log, and, if they had any, deactivated rows in Dashboard > Agents.
OIN listing status
PasskeyBridge is preparing an Okta Integration Network submission. There is no published listing today and no partner status to cite, so add PasskeyBridge as a custom SCIM 2.0 app as described above.
The submission assets live in the repository: the SCIM endpoint, the Event Hook consumer, docs/okta-oin-manifest.json and docs/openapi.yaml.
The points a reviewer usually checks, stated accurately: PasskeyBridge stores no plaintext identifiers, digesting them with keyed HMAC-SHA-256 under a server-held pepper; every SCIM operation and hook delivery is logged with a keyed IP digest and its latency; and PATCH requests are validated against RFC 7644 section 3.5.2, answering SCIM error types. There is no sandbox mode for a reviewer to exercise, and the review account must be a real PasskeyBridge signup like any other user.
Questions about the listing go to partnerships@passkeybridge.io.
Related from the blog
- Feeding a Hard Deny into Your Fraud Rules Engine: Integration Patternsengineering · 10 min read
- The Pre-Filter Pattern: Paying for Heavy Fraud Signals Only on the Suspicious Tailsecurity · 9 min read
- Ground Truth for Space-Based Auth: Validating Doppler Challenges Against Public TLE Catalogsntn · 11 min read