Shared Signals receiver
Receive signed CAEP and RISC security events from an organization's identity providers by push or poll, match them to people, and end sessions.
An organization's identity provider often knows things about its people that Better IAM does not: a password turned up in a breach, the security team ended all of someone's sessions, a sign-in looks high risk, an account was disabled. Without a signal, Better IAM finds out at the next sign-in through that provider, or never, and the person's sessions here stay valid in the meantime.
The OpenID (SSF 1.0) lets the provider send these facts as signed Security Event Tokens (SETs, RFC 8417) with the CAEP and RISC event types. Better IAM receives them:
- Each organization registers its providers as signal sources: issuer, keys, accepted audiences, delivery.
- Events arrive by push (RFC 8935, to a URL the source is given) or by poll (RFC 8936, fetched by the
scheduler job
iam.signals.poll()). - Each SET is verified against the source's settings and deduplicated on its issuer and
jti. - Its subject is matched to a person of the source's organization: through federation links, SCIM-provisioned users, or email addresses at verified domains.
- The event is recorded for 90 days and audited as
signal:received, which threat detection reads. - Where the source asks for it, the person's sessions end at once. Nothing more drastic happens on its own: disabling or containing an account is left to threat-detection playbooks.
To send Better IAM's own events to other systems instead, see the Shared Signals transmitter.
Who configures it. Organization administrators register their providers, usually from your admin UI. Reading
sources and events needs iam:signals:read; registering and changing sources, reprocessing events, and polling on
demand need iam:signals:manage. Both are checked on iam/signals/sources and iam/signals/events (with /{id} for
one record), and every change also needs a . Your team sets the
deployment options and schedules the poll job once poll sources exist.
Supported events
| Event type | Profile | What the provider reports |
|---|---|---|
session-revoked | CAEP | A session of the person was ended |
credential-change | CAEP | A credential was created, changed, revoked or deleted (credential_type, change_type) |
token-claims-change | CAEP | Claims the provider asserts about the person changed |
assurance-level-change | CAEP | The person's authentication assurance level changed |
device-compliance-change | CAEP | A device of the person became compliant or non-compliant |
risk-level-change | CAEP | The provider's risk level for the person changed (current_level) |
account-disabled | RISC | The account was disabled, for example because it was hijacked |
account-enabled | RISC | The account was enabled again |
account-purged | RISC | The account was deleted |
account-credential-change-required | RISC | The person must change a credential |
credential-compromise | RISC | A credential of the person was compromised |
identifier-changed | RISC | An identifier of the account (email, phone number) changed |
identifier-recycled | RISC | An identifier now belongs to a different account |
sessions-revoked | RISC | All sessions of the person were ended (deprecated by RISC in favour of CAEP) |
verification | SSF | The provider is testing the stream. Noted as lastVerifiedAt on the source. |
stream-updated | SSF | The provider changed the stream's status. A pause or disable is noted as lastError. |
Any type but the two control events can be set to end the person's sessions. Types are recognized by their exact URI
(https://schemas.openid.net/secevent/caep/event-type/session-revoked and so on). The control events are also
accepted under the RISC namespace, where Google still sends them, and that of the interim SSE drafts; they never
touch a person. Any other type, such as RISC's OAuth tokens-revoked, is recorded as ignored (unsupported-event).
Register a source
const { source, pushUrl, pushToken } = await iam.api.signals.createSource(admin, {
tenantId,
name: 'Acme Okta',
issuer: 'https://acme.okta.com',
issuerAliases: ['https://acme.okta.com/oauth2/default'],
audiences: ['https://iam.example.com'],
delivery: 'push',
subjects: {
connectionIds: ['acme-okta'], // the organization's Okta sign-in connection
scimConnectionIds: ['acme-okta-scim'], // the SCIM connection Okta provisions through
matchEmail: true,
},
actions: { 'session-revoked': 'revoke-sessions', 'credential-compromise': 'revoke-sessions' },
});
// Configure the provider to POST to pushUrl with `Authorization: Bearer ${pushToken}`.
// The token is returned only here; rotatePushToken issues a new one.Prop
Type
An organization may register at most 20 sources (LIMIT_EXCEEDED). Nothing is fetched at registration: URLs are
only checked against the address rules. Ask the provider for a verification event after setup:
lastVerifiedAt on the source shows it arrived, and lastError explains a refusal. Some transmitters, Better IAM's
own included, address SETs to the push URL unless told otherwise. That URL contains the source's ID, so add it to
audiences with updateSource after creation, or configure an explicit audience at the transmitter.
Manage sources
listSourcesandgetSourcenever return secrets (hasPushTokenstands in for the push token) and report health:lastEventAt,lastVerifiedAt,lastError, and for poll sourcespoll.lastPolledAtandpoll.pendingAcks. The poll endpoint is visible to anyone withiam:signals:read, so keep secrets in the poll token, never in the URL.updateSourcechanges any field exceptissueranddelivery.jwks: nullorjwksUri: nullswitches the source to discovery. A new poll endpoint needs its token again and starts with an empty acknowledgement queue, so a stored token never goes to an endpoint it was not given for.status: 'disabled'makes pushes answer 404 and stops polling.actionsreplaces the whole map,subjectsonly the members given, andtenantClaim: nulldrops the claim check. Any change drops the source's cached keys.rotatePushTokenreturns a new bearer token, shown once; the old one stops working at once. It also gives a token to a push source created without one.deleteSourceremoves the source: its pushes answer 404, and the events it sent stay until they expire.
Push delivery
The provider sends each SET in its own request:
POST /api/iam/signals/push/{sourceId} HTTP/1.1
Host: iam.example.com
Content-Type: application/secevent+jwt
Authorization: Bearer {pushToken}
eyJhbGciOiJSUzI1NiIsInR5cCI6InNlY2V2ZW50K2p3dCIsImtpZCI6Ii4uLiJ9.eyJpc3MiOiJodHRwczovL...The push URL (pushUrl on the source) is the baseURL origin followed by {signals.pushPath}/{sourceId}, where
pushPath defaults to {basePath}/signals/push. The body is at most 64 KiB and the SET at most 16 KiB. The bearer
token is required only when the source has one, and is compared in constant time against its stored SHA-256 hash.
| Status | err | Meaning |
|---|---|---|
| 202 | (empty body) | The event is committed, or its issuer and jti arrived before (nothing is done again). |
| 400 | invalid_request, invalid_key, invalid_issuer, invalid_audience | The SET was refused (see Verification); description says why. Resending gets the same answer. |
| 401 | authentication_failed | The bearer token is missing or wrong (with WWW-Authenticate: Bearer). |
| 403 | access_denied | The sender's address is in one of the organization's network blocks. |
| 404 | invalid_request | No active push source has that ID: unknown, deleted, disabled, a poll source, or its organization is suspended. |
| 405, 413, 415 | invalid_request | Not POST (with Allow: POST); a body over 64 KiB; a content type other than application/secevent+jwt. |
| 429 | invalid_request | More than 3000 events from the source in the rate-limit window, with Retry-After. |
| 503 | invalid_key, invalid_request | The source's keys could not be obtained (Retry-After: 30) or storage was busy (Retry-After: 1): send it again. |
| 500 | invalid_request | Processing failed unexpectedly. |
Error bodies are RFC 8935 JSON, { "err": "...", "description": "..." }, and every response carries
Cache-Control: no-store. A 202 comes after the event is stored and its action has run, with no queue in between.
The rate-limit window is authentication.rateLimits.windowMs
(15 minutes by default). Refusals are also noted as the
source's lastError, at most once a minute for the same message, so administrators see why events are not arriving.
The push endpoint is a protocol mount served by iam.handler and iam.nodeHandler before the API routes, so it
needs neither the X-Better-IAM header nor an Origin. With the default pushPath, the route that serves the IAM
API serves pushes too. If you move pushPath outside basePath, route that path to the IAM handler as well.
Poll delivery
A poll source pulls its events from the provider's RFC 8936 endpoint. Each run of iam.signals.poll(), for every
active poll source:
- Requests with
POST { "maxEvents": 25, "returnImmediately": true, "ack": [...], "setErrs": {...} }andAuthorization: Bearer {token}(setErrsonly when there are errors to report): a 10-second timeout, no redirects, a response of at most 1 MiB. - Records each SET of
{ "sets": { "{jti}": "{SET}" }, "moreAvailable": true | false }exactly as a push would be. Eachjtimust equal its key insets. - Acknowledges committed events, duplicates included, with the next request (possibly the first of the next
run), never before they are stored. Refused events go into
setErrswith the same codes as a push refusal. Events that could not be processed yet (keys unavailable, storage busy) are neither acknowledged nor reported, so the provider delivers them again. - Repeats while the provider answers
moreAvailable: true, up to five requests per source and run.
A run returns { sources, received, acknowledged, errors }: sources polled, new events, acknowledgements the provider
accepted, and failed requests plus events left for redelivery. A failed request is noted as the source's lastError
(such as "The poll endpoint answered 500"), and pending acknowledgements wait for the next attempt. There is no long
polling, so the job's interval is the delay. signals.poll({ tenantId, sourceId }) polls one source now, for a
"Check now" button.
The poll token is sealed with the deployment secret, bound to its source, and re-sealed by iam.rotateSecrets()
(see Secrets). A token sealed under a secret that is no longer configured
cannot be opened: the source shows that as lastError until an administrator gives the token again.
Verification
A SET is accepted only if all of the following hold:
- Shape. It is a compact JWS of at most 16 KiB. Encrypted SETs (JWE) and unsigned tokens are refused.
- Keys come from the source, never from the token. Headers carrying key material or key locations (
jwk,jku,x5u,x5c) are refused, and so is anycritheader. - Algorithm. The header's
algis one of the source'salgorithms. - Type.
typissecevent+jwt, compared without regard to case and with or without theapplication/prefix. This keeps other tokens signed with the same keys, such as ID tokens and access tokens, from passing as events. A source withrequireTyp: falsealso accepts notypat all, orJWT. - Signature. A key of the source verifies the signature. When several keys could match (a token without a
kid, or keys without one), each candidate is tried, up to 20. - Issuer and audience.
issis the source's issuer or one of its aliases, with or without a trailing slash, andaud(a string or a list of at most 20) names one of the source's audiences. - Organization. When the source has a
tenantClaim, the SET'stenant_idequals it (elseinvalid_audience). - Time.
iatis at most five minutes in the future and at most seven days old. There is no shorter age limit, because a polled event may wait in the provider's queue.expandnbfare honoured when present, with five minutes of tolerance. - Claims.
iss,aud,iat,jtiandeventsare present, andeventsholds exactly one event whose value is an object, nested at most ten levels deep. Anonceclaim is refused, since only ID tokens carry one.iss,jti,txnand each audience are strings of at most 512 characters. - Subject. The subject, when there is one, is a valid subject identifier (below). If both a top-level
sub_idand a legacyevents[type].subjectare present, they name the same subject, and a top-levelsubdoes not contradict aniss_subsubject of the same issuer.
Refusals answer invalid_key for key, algorithm and signature problems, invalid_issuer for the issuer,
invalid_audience for the audience and the organization, and invalid_request for everything else.
Subject identifiers. The subject is read from the top-level sub_id (RFC 9493) or the legacy
events[type].subject. The accepted formats are iss_sub, email, opaque, account, phone_number, did and
uri; aliases with at most 10 identifiers and no alias list inside another; SSF complex with the members user,
session, device, tenant, group, application and org_unit; and legacy RISC subjects whose subject_type is
iss-sub, email, phone or id_token_claims. Every string is 1 to 512 characters without control characters, and
nesting goes at most three levels deep. Any other format is refused. The subject is stored as parsed, up to 8 KiB. The
event's own claims are stored up to 4 KiB; beyond that, the members that fit are kept and _truncated: true is added.
Duplicates. Within an organization, a SET is identified by its source's issuer and its jti. A repeated one is
answered as accepted (and acknowledged, when polled), and nothing is done again, even when the same event arrives
twice at the same moment.
Keys
- Static keys (
jwks) are used as given. To roll them, update the source with both the old and the new keys, then remove the old ones once the provider has switched. - A key URL (
jwksUri) is fetched through the guarded transport: https on a public address, no redirects, at most 256 KiB, a five-second timeout. The key set is cached for ten minutes. A token whosekidthe cached set lacks causes a refetch, at most every 30 seconds. - Discovery, used when a source has neither, reads
{issuer origin}/.well-known/ssf-configuration{issuer path}, then the legacy/.well-known/risc-configuration{issuer path}. The document'sissuermust name the source (its issuer or an alias), and itsjwks_uriis then used under the same rules. A discovered key location is refreshed daily. A failed discovery is retried after 30 seconds at the earliest, and a failed refresh keeps the keys found before.
Keys that cannot be obtained prove nothing wrong with the event: a key set that cannot be fetched, an answer other
than 200, a body that is not a public key set, a failed discovery, or a token signed by a new remote key that is not
published yet. These refusals are temporary: a push answers 503 and a polled event stays unacknowledged, so the
provider delivers it again. With static keys, an unknown kid is a permanent invalid_key.
Match subjects to people
| Subject | Matches | Needs |
|---|---|---|
iss_sub whose iss is the source's issuer or an alias | First, the person linked to that subject by a sign-in through one of connectionIds. Then, the person provisioned through one of scimConnectionIds whose SCIM externalId is the sub. | connectionIds or scimConnectionIds |
opaque | The person provisioned through one of scimConnectionIds whose externalId is the id | scimConnectionIds |
email, account (acct:user@host) | The person with that email address, when its domain is a verified domain of the organization | matchEmail: true |
complex | Its user member, matched as above. Session, device, organization, group and application members name no person. | |
aliases | Each identifier in turn, until one matches | |
phone_number, did, uri, iss_sub of other issuers | Nobody |
- Federation links are created when a person signs in through an OAuth, OIDC or SAML connection of the
organization, keyed by the connection, the issuer its tokens name, and the subject. The receiver tries the SET's
issuer with and without a trailing slash, and every alias of the source, so add the sign-in connection's issuer as
an alias when it differs (an Okta custom authorization server). SAML links store the identity provider's entity ID
and the NameID, so they match only when the provider's
iss_subsubjects use those same values. Usually they do not, and SCIM or email is the better choice. - SCIM
externalIdis the provider's own user ID in most directories, and often the same value itsiss_subandopaquesubjects carry. See SCIM inbound. - Email matches only at domains the organization has verified with the
domainsAPI. Anyone can hold an address at a domain the organization does not control, so an unverified domain never matches. Addresses are trimmed and lowercased before the lookup. - Never matched: , AI agents, deleted identities, and anyone in another organization, child organizations included.
- Sessions. Better IAM does not know a provider's own session IDs, so a
session-revokedevent about one session of a person ends all of that person's sessions when the action isrevoke-sessions.
An event that matches nobody is kept as unmatched, with reason no-match, or no-subject when the SET named none.
After fixing the mapping or the directory, signals.reprocess({ tenantId, eventId }) maps it again.
What happens to an event
Each event is handled in one transaction: verified, deduplicated, applied, stored, audited, and noted as the
source's lastEventAt. Its status is one of:
| Status | Meaning |
|---|---|
applied | The source's action for the event type (revoke-sessions) ran. |
recorded | Kept: it matched a person and the action is record, or it was a control event. |
unmatched | No person of the organization matched its subject (no-match, no-subject). |
ignored | An unsupported event type (unsupported-event), or an action refused for a protected person (protected). |
failed | Applying it failed; the reason is the error code. It is stored in a transaction of its own and acknowledged, so the provider does not send it again, and an administrator can reprocess it. |
Actions. record, the default, keeps the event and does nothing else. revoke-sessions ends every
of the person except API keys: sign-in sessions, role sessions and session tokens
(with sessions opened through them), role sessions the person assumed in
other organizations, and pending sign-in challenges. Remembered devices and API keys stay, so the next sign-in works
as usual. It is audited as signal:revoke-sessions with the number of sessions ended. If you run the
OAuth provider, its logoutEndedSessions() sweep then signs the person out of the
connected applications too.
Root administrators are protected
A source of any organization except the root organization never ends a root administrator's sessions. The event
is ignored with reason protected.
Reprocessing. signals.reprocess handles an unmatched or failed event of an active source again, with the
source's current mapping and action, as if it had just arrived. An event that now matches someone is audited as
signal:received again, so threat detection sees it; the call itself is audited as signal:reprocess. Link the
person first (a sign-in through the connection, SCIM provisioning, or a verified domain), then reprocess.
Threat detection and containment
The receiver never disables or contains an account by itself: what an upstream "disabled" or "compromised" should
mean differs between organizations. Instead, every received event is audited as signal:received, by the actor
signal:{sourceId}, on the matched identity (or signals/sources/{sourceId} when nobody matched), with the metadata
{ sourceId, eventType, jti, status, identityId?, reasonAdmin?, currentLevel?, credentialType? }. reasonAdmin is
CAEP reason_admin (English, else the first language given, at most 256 characters), currentLevel the upper-cased
current_level of a risk-level-change, and credentialType the event's credential_type.
Threat detection's upstream-signal rule turns matched events into detections about the person:
| Event | Detection |
|---|---|
credential-compromise | high |
risk-level-change to HIGH | high |
risk-level-change to MEDIUM | medium |
account-disabled, account-purged, account-credential-change-required | medium |
credential-change | low |
session-revoked, risk-level-change to LOW, and the rest | none |
A detection raises the person's risk, which policies read as principal.riskLevel. It also opens an incident and
runs the organization's playbooks, and that is where containment belongs:
await iam.api.threats.createPlaybook(admin, {
tenantId,
name: 'Contain people our identity provider reports compromised',
trigger: { ruleIds: ['upstream-signal'], minSeverity: 'high' },
actions: [{ kind: 'contain' }, { kind: 'notify' }],
});The playbook protections apply: owners and root administrators are never contained automatically, and the brake
limits how many people one run contains. To forward received events to a SIEM, subscribe a
webhook to signal:*.
Avoid echo loops
A provider can be both a transmitter and a receiver (Okta, or another Better IAM deployment). If the receiver's
actions were audited under names the transmitter publishes, a revocation received from a provider would be sent
straight back to it. The receiver therefore records everything under its own signal:* names, which the
transmitter never maps: signal:revoke-sessions is not
identity:revoke-sessions. The trade-off is that receivers of your own streams are not told about sessions that
ended because of an upstream signal.
Threat-detection responses are different: the transmitter sends threat:revoke-sessions as CAEP session-revoked
and threat:contain as RISC account-disabled. A playbook that contains someone because of a provider's signal
therefore tells every stream of the organization, including the provider's own. If that provider should not hear
back about containments it caused, leave those event types out of its stream's events.
Set up providers
For every provider, find out its SET issuer (and whether its sign-in tokens use another one, to add as an alias),
whether discovery finds its keys, the aud it sends, how it delivers (push URL and bearer token, or its poll
endpoint and token), its subject format and which mapping reaches your people, and which event types it sends and
which should end sessions. Then have it send a verification event and check lastVerifiedAt and lastError.
Another Better IAM deployment's transmitter needs no guesswork:
// Downstream deployment: register the upstream transmitter.
const { source, pushUrl, pushToken } = await downstream.api.signals.createSource(admin, {
tenantId,
name: 'Acme identity',
issuer: 'https://id.acme.example/oidc', // the transmitter's issuer
jwksUri: 'https://id.acme.example/oidc/jwks', // or leave it out when upstream serves signals.handler
audiences: ['https://iam.example.com/signals/acme'],
tenantClaim: upstreamTenantId, // only events about this upstream organization
delivery: 'push',
subjects: { connectionIds: ['acme-oidc'] },
actions: { 'session-revoked': 'revoke-sessions' },
});
// Upstream deployment (`signals` is its createSharedSignalsTransmitter instance): stream the organization's events.
const stream = await signals.createStream(upstreamAdmin, {
tenantId: upstreamTenantId,
name: 'Example IAM',
endpointUrl: pushUrl!,
audience: 'https://iam.example.com/signals/acme',
authorization: `Bearer ${pushToken}`,
});
await signals.verifyStream(upstreamAdmin, { tenantId: upstreamTenantId, streamId: stream.id });A Better IAM transmitter signs every organization's events with the same issuer and key and names the organization
in a tenant_id claim, so set tenantClaim to the upstream organization's ID: the source then accepts only that
organization's events. Its iss_sub subjects carry the upstream identity ID, which matches federation links when the
organization signs in through an OIDC connection to the upstream OAuth provider (whose issuer the transmitter usually
shares). Otherwise, create the stream with subjectFormat: 'email' and turn on matchEmail.
Deployment
const iam = betterIam({
// ...
signals: {
pushPath: '/api/iam/signals/push', // the default: `${basePath}/signals/push`
allowPrivateNetworks: false,
allowInsecureLocalhost: false,
},
});Prop
Type
Every outbound request (keys, discovery documents, polls) goes through the guarded transport. It checks the address at connect time, which defeats DNS rebinding, refuses redirects, and bounds the response size and time.
setInterval(() => void iam.signals.poll().catch(reportError), 60_000).unref();- Custom transports.
iam.signals.receive(sourceId, set)verifies and records one SET for an active source exactly as a push does, for example from a queue consumer, and returns{ eventId, status, duplicate, identityId? }. It throwsSignalRejectedError(SIGNAL_REJECTED, with the RFC 8935err, andtemporary: truewhen a later attempt may pass), orNOT_FOUNDfor an unknown or disabled source. - Storage and retention. Sources (
signalSources) and received events (signalEvents) belong to the organization and are deleted with it. The retention sweep (iam.sweepExpired()) removes events 90 days after receipt; the audit trail keeps theirsignal:receivedevents. Push tokens are stored as SHA-256 hashes, and poll tokens sealed. - Transport and regions. The push endpoint caps its body at 64 KiB on every transport. In a multi-region deployment, give providers the push URL of the organization's : transmitters, Better IAM's included, do not follow redirects.
Audit events
Besides the signals group's operation events (iam:signals:read, iam:signals:manage), the receiver records
signal:received, signal:revoke-sessions, signal:source-create, signal:source-update, signal:source-delete,
signal:source-rotate and signal:reprocess, subscribable as signal:*. Events about received SETs are recorded by
the actor signal:{sourceId}, and changes to sources by the administrator who made them.
Limits
| Limit | Value |
|---|---|
| Sources per organization | 20 |
| Issuer aliases, audiences, static keys | 5, 10, 20 |
| Connection IDs per mapping list | 10 |
| Push request body / SET | 64 KiB / 16 KiB |
| Pushes per source | 3000 per rate-limit window |
| Poll requests per source and run | 5, each with maxEvents (1 to 100, 25 by default) |
| Poll response | 1 MiB, 10 seconds |
| Key set / discovery document | 256 KiB / 64 KiB, 5 seconds each |
| SET age | At most 7 days old, at most 5 minutes in the future |
| Stored event claims / subject | 4 KiB / 8 KiB |
| Received event retention | 90 days |
Not offered
- Stream management as a receiver. The receiver does not use a provider's SSF stream configuration, status, verification or subject endpoints. Streams are set up at the provider by an administrator, and verification events are requested there.
- Encrypted SETs (JWE).
- Long polling. Polls always ask the provider to return immediately.
- Matching upstream sessions. Session-level events end all of the person's sessions.
- Deployment-wide sources. Each source belongs to one organization and matches only that organization's people.
Next steps
Better IAM is created by Sean Filimon
Last updated
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.
SCIM inbound
Let an organization's directory create, update, deactivate, and delete its people and groups through connection-scoped SCIM 2.0 endpoints.