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.

Last reviewed September 17, 2026Fresh

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:

CapabilityEffect in PasskeyBridge
User provisioningThe existing account is added to the tenant as a member, with scim_active true
Attribute updatesdisplayName updates the profile; externalId and active update the membership
Deprovisioningscim_active is set to false on the membership row
Group pushThe member's role becomes admin, member or viewer
Event HooksLifecycle 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 scim scope, and a second carrying okta_hooks if 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.

  1. In the Okta Admin Console, create a new app integration and choose SCIM 2.0 provisioning.
  2. Fill in the connection settings.
FieldValue
SCIM connector base URLhttps://api.passkeybridge.io/v1/shield-scim
Unique identifier field for usersuserName
Authentication ModeHTTP Header
AuthorizationBearer followed by your scim-scoped API key
  1. Test the connection. Okta calls GET /ServiceProviderConfig, which needs no authentication and answers 200 with application/scim+json. The document advertises PATCH supported, bulk unsupported, filtering supported with a maximum of 200 results, and OAuth bearer-token authentication.
  2. 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 attributeOkta sourceStored asNotes
userNameuser.loginResolved to the PasskeyBridge account id on shield_tenant_members.user_idMust match an existing account's email address
externalIduser.idshield_tenant_members.external_idUnique within the tenant; a duplicate answers 409
displayNameuser.displayNameprofiles.display_nameFalls back to name.formatted, then to userName
activecomputed by Oktashield_tenant_members.scim_activeFalse 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 idRole granted
{tenant_id}:adminFull tenant management
{tenant_id}:memberDashboard access and event visibility
{tenant_id}:viewerRead-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.

  1. In the Okta Admin Console, go to Workflow > Event Hooks and create a hook.
  2. Set the URL to https://api.passkeybridge.io/v1/shield-okta-hooks.
  3. Add an Authorization header holding Bearer and an API key with the okta_hooks or ingest scope.
  4. Add a second header, x-pb-tenant-id, holding your tenant id. Without it the delivery answers 400.
  5. 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 eventEffect
user.lifecycle.suspendAgent delegates deactivated with reason okta:user.lifecycle.suspend, cached proofs invalidated, okta_user_suspended event written
user.lifecycle.deactivateTenant membership deleted, delegates deactivated, shadow identities revoked, okta_user_deactivated event written with result fully_deprovisioned
user.lifecycle.unsuspendokta_user_unsuspended event written; delegates stay off until you re-approve them
user.session.startokta_session_start event written, with a keyed digest of the source IP
user.account.update_passwordokta_password_changed event written with risk_score 0.6
user.mfa.factor.deactivateokta_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.

FieldContents
operationuser.create, user.replace, user.patch, user.delete, user.deactivate, group.add_member, group.remove_member or group.create
resource_typeUser or Group
resource_idThe PasskeyBridge account id, or the group id
external_idThe Okta externalId, when the request carried one
scim_payloadactive, role and operations_count in the clear, plus keyed HMAC-SHA-256 digests of userName, externalId, displayName and any member value
resultsuccess or error
error_detailThe failure string, for example no_account_for_userName or User already exists
api_key_idWhich key made the request, which separates Okta traffic from manual calls
ip_hashKeyed digest of the source IP
user_agentFirst 512 characters of the header
latency_msServer-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:

DetailStatusSCIM typeCause
userName does not match a PasskeyBridge account400invalidValueThe person has never signed up for PasskeyBridge
userName is required400invalidValueOkta mapped nothing into userName
Invalid PATCH op400invalidValueOnly add, remove and replace are supported
Request body is not valid JSON400invalidValueMalformed payload
User already exists409uniquenessThat account is already a member of this tenant
externalId already exists409uniquenessAnother membership already carries that externalId
Invalid SCIM bearer token401noneUnknown or inactive key
API key does not have 'scim' scope403noneThe 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.

  1. Connectivity. Run the Okta connection test, or curl /ServiceProviderConfig. Expect 200 and application/scim+json.
  2. 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.create row with result success in Dashboard > Settings > SCIM provisioning.
  3. Attribute update. Change their display name in Okta. Expect a user.patch or user.replace row, and the new name on the member.
  4. Group push. Push them into the group mapped to admin. Expect a group.add_member row and the role changing in Dashboard > Team.
  5. Deprovision. Unassign them in Okta. Expect a user.deactivate row and scim_active false. Dashboard access does not end here. Deactivation marks the membership row and keeps it, so remove the person in Dashboard > Team, or send the user.lifecycle.deactivate Event Hook, which deletes the membership outright.
  6. Event Hooks. Suspend them in Okta and look for an okta_user_suspended row in Dashboard > Events, an okta.hook.user.lifecycle.suspend entry 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