BetterIAM

Shared Signals

Push signed CAEP and RISC security events about a tenant's people to SIEMs, applications, and partner IdPs as sessions end and accounts change.

Revoking a in Better IAM ends it here, but other systems keep their own state. An app that turned an ID token into its own cookie still thinks the person is signed in. A partner identity provider (IdP) still trusts the account. The company's security team never hears, in their security information and event management system (SIEM), that a password was reset. Without a signal, those systems find out hours later, or never.

The OpenID Shared Signals Framework (SSF) is the standard for sending such signals between systems. It defines two sets of event types:

  • CAEP (Continuous Access Evaluation Profile) covers events that should make a receiver re-check an active session right now, such as "this session was revoked" or "this person's credentials changed".
  • RISC (Risk Incident Sharing and Coordination) covers account-level events, such as "this account was disabled", "deleted", or "its email address changed".

Each event travels as a Security Event Token (SET, RFC 8417): a small JSON Web Token (JWT) signed by the sender, so the receiver can verify where it came from. Better IAM pushes SETs to the receiver's HTTPS endpoint (push delivery, RFC 8935) and retries until they are accepted.

createSharedSignalsTransmitter makes Better IAM an SSF transmitter. It turns IAM activity (sessions revoked, credentials changed, identifiers changed, accounts disabled or deleted) into signed SETs and pushes them to each 's receivers. The activity comes from the same audit events that feed the and .

Who configures it. Your team sets up the transmitter and its delivery job once. Tenant administrators (often the customer's IT or security team) then add streams, one per receiver, with the endpoint and credentials the receiver's operator gives them.

Set up the transmitter

Create the transmitter with the host callbacks and a signing key, connect it to IAM's events, and schedule its retries. The example also creates a first stream, which an administrator would normally do from your admin UI:

signals.ts
import { createSharedSignalsTransmitter, sharedSignalEvents } from 'better-iam/oauth';

export const signals = createSharedSignalsTransmitter({
  ...iam.protocolHost,
  issuer: 'https://id.example.com/oidc', // usually the OAuth issuer; its /jwks publishes the public keys
  jwks: secrets.privateSigningJwks,
  encryptionKey: secrets.base64Encoded32ByteKey,
});
signals.subscribe(iam.events); // publish as IAM events are dispatched
setInterval(() => void signals.dispatch(), 60_000).unref(); // retries

await signals.createStream(credential, {
  tenantId,
  name: 'Acme SIEM',
  endpointUrl: 'https://siem.acme.com/ssf/events',
  authorization: 'Bearer receiver-issued-token',
  events: [sharedSignalEvents.sessionRevoked, sharedSignalEvents.credentialChange],
  subjectFormat: 'email',
});

Prop

Type

When the transmitter shares the OAuth provider's issuer and keys, the provider's /jwks already publishes the public half. Otherwise publish it yourself at jwksUri.

Three functions move events:

  • subscribe(iam.events, { onError? }) listens to IAM events, turns matching ones into SETs, and delivers them at once. Call it at startup. It returns an unsubscribe function.
  • dispatch({ limit? }) sends deliveries that are due, including retries (at most 200 per call by default), and prunes delivered records older than a week. Run it on an interval; see Protocol jobs.
  • publish(auditEvent) queues SETs for one audit event yourself, for example when replaying events, and returns the number of streams addressed.

Serve the transmitter metadata

Receivers learn about a transmitter from a standard metadata document. signals.handler(request) serves it at /.well-known/ssf-configuration{issuer path} and returns undefined for every other request. Put it in front of your other handlers:

export async function handle(request: Request): Promise<Response> {
  return signals.handler(request) ?? (await iam.handler(request));
}

The document names the issuer, the jwks_uri, push delivery (urn:ietf:rfc:8935) as the only delivery method, spec_version: "1_0", and default_subjects: "ALL".

Event mapping

The transmitter watches for these audit actions and turns each into one standard event. Other activity, including SCIM provisioning changes, produces no event.

IAM activityAudit actionsEvent
Sign-out of a session, "sign out other devices", administrator session revocation, tenant-wide revocationauth:session:revoke, auth:session:revoke-others, identity:revoke-sessions, tenant:revoke-sessionsCAEP session-revoked
Password changed or resetauth:password:change, auth:password:resetCAEP credential-change (credential_type: password, change_type: update)
Authenticator app added or removedauth:mfa:enable, auth:mfa:disableCAEP credential-change (credential_type: app, change_type: create or delete)
Passkey added or removedauth:passkey:create, auth:passkey:deleteCAEP credential-change (credential_type: fido2-platform, change_type: create or delete)
Email address changed, by the person or an administratorauth:email:change, identity:email-changeRISC identifier-changed
Offboarding, scheduled expiryidentity:offboard, identity:expireRISC account-disabled
Deletionidentity:deleteRISC account-purged

Only allowed (successful) actions produce events. The sharedSignalEvents export names the five event type URIs, for a stream's events list: sessionRevoked, credentialChange, identifierChanged, accountDisabled, and accountPurged.

What receivers typically do with them: end their own session on session-revoked, ask for a fresh sign-in or flag the account on credential-change, update their copy of the email on identifier-changed, and block or remove the account on account-disabled and account-purged.

What receivers get

Receivers verify the signature with your public keys, then read who the event is about and what happened. Each SET is signed with the first private key that has an alg, with typ: secevent+jwt and the key's kid. Decoded, a session revocation looks like this:

{
  "iss": "https://id.example.com/oidc",
  "aud": "https://siem.acme.com/ssf/events",
  "jti": "0b6f5e1c-4d0a-4a8b-9f3e-7c2d1e5a9b40",
  "iat": 1790000000,
  "txn": "audit-event-id",
  "sub_id": { "format": "iss_sub", "iss": "https://id.example.com/oidc", "sub": "identity-id" },
  "events": {
    "https://schemas.openid.net/secevent/caep/event-type/session-revoked": {
      "event_timestamp": 1790000000,
      "initiating_entity": "admin"
    }
  }
}
  • aud is the stream's audience, which defaults to its endpoint URL.
  • txn is the ID of the IAM audit event that caused it, so receivers can correlate with your audit log.
  • sub_id says who the event is about. It is { format: "iss_sub", iss, sub: identityId } by default, or { format: "email", email } when the stream asks for subjectFormat: 'email' and the address still exists. Tenant-wide revocation uses a complex subject naming the tenant.
  • event_timestamp is when the activity happened. initiating_entity is user when the person acted on their own account, otherwise admin.

Streams

A stream is one receiver of one tenant's events: the customer's SIEM, one of their applications, a partner IdP. Administrators configure streams, usually from a settings screen in your admin UI; receivers cannot create or change them.

Prop

Type

MethodPermission on ssf/{streamId}What it does and when to use it
createStream(credential, input)iam:ssf:streams:createAdds a receiver.
listStreams(credential, { tenantId })iam:ssf:streams:readLists streams with health fields: pending deliveries, lastDeliveredAt, lastError, and hasAuthorization. Use it for a status page.
getStream(credential, { tenantId, streamId })iam:ssf:streams:readReads one stream.
updateStream(credential, { tenantId, streamId, ...changes })iam:ssf:streams:updateChanges settings, pauses or resumes, or replaces the header. authorization: null removes the header.
deleteStream(credential, { tenantId, streamId })iam:ssf:streams:deleteRemoves the stream and drops its undelivered events.
verifyStream(credential, { tenantId, streamId, state? })iam:ssf:streams:updateSends an SSF verification event right away and reports whether the receiver accepted it. Use it after setup to test the endpoint, credentials, and keys in one call.
listDeliveries(credential, { tenantId, streamId, status?, limit? })iam:ssf:streams:readRecent deliveries, newest first (default 50, at most 500), filterable by pending, delivered, or failed. Use it to debug a receiver.

Stream changes are audited as iam:ssf:CreateStream, iam:ssf:UpdateStream, and iam:ssf:DeleteStream. The receiver's authorization header is encrypted with encryptionKey and never returned. Endpoints must use HTTPS (allowInsecureLocalhost allows loopback HTTP for development).

verifyStream sends a verification event whose state echoes back to the receiver, and returns { delivered, error?, jti }.

Delivery

Receivers go offline for maintenance, so delivery is queued and retried rather than attempted once. Each delivery moves through these states:

  • Deliveries are POST requests with Content-Type: application/secevent+jwt and the stream's authorization header. Redirects are not followed.
  • They expect 202, and also accept 200 and 204. Any other answer records HTTP and the status, with the receiver's err and description when it sends them, as the stream's lastError.
  • Failures retry with backoff: 30 seconds, 2 minutes, 8 minutes, about 30 minutes, then every 2 hours, for eight attempts in total. After that the delivery is failed.
  • A paused stream (enabled: false) keeps collecting events and delivers them after it is re-enabled. Deleting a stream drops its queue. A suspended tenant's deliveries fail and retry.

Not offered

Receiver-driven stream management (the SSF stream configuration API, where a receiver creates its own stream) and poll delivery (where a receiver fetches events) are not offered: streams are configured by administrators, and events are pushed.

Next steps

Was this page helpful?

Better IAM is created by Sean Filimon

Last updated

On this page