Design-and-scope overview of the Shared Signals Framework (SSF) transmitter
feature landed on branch issue/gh-xxx-ssf-tx-support-v1. Keycloak acts as
an SSF Transmitter — receivers authenticated via OAuth pull signed
Security Event Tokens (SETs) describing user / session / credential events
that happen in the realm.
Feature flag: Profile.Feature.SSF — experimental, opt-in.
Shared Signals Framework (SSF, OpenID Final 1.0) lets one identity system push security-relevant signals (session revoked, credential changed, account disabled, assurance level changed, …) to other relying parties in near real time, so a sign-out in Keycloak can log a user out of downstream SaaS apps, a password change can invalidate cached sessions elsewhere, etc. The signals are JWS-signed SETs (Security Event Tokens, RFC 8417) where the contained events are described by profiles such as CAEP (OpenID Continuous Access Evaluation Profile) and RISC (OpenID Risk Incident Sharing and Coordination Profile) ; receivers verify them against the transmitter's JWKS and act on them locally.
Keycloak plays the Transmitter role. A Keycloak client can be marked
as an SSF receiver (via the ssf.enabled=true client attribute) and represents the SSF Receiver metadata in Keycloak; that
client then creates a stream via the SSF management API describing
what it wants delivered (events, delivery method, endpoint). A stream is like a subscription for events for a given SSF Receiver.
Currently, there can only be one stream per SSF Receiver client in Keycloak.
Keycloak's internal event listener observes user / session / credential events,
maps them to typed SSF SETs, signs them, and delivers them to the
stream's receiver — either by PUSH (RFC 8935)
to a receiver-supplied URL, or by POLL (RFC 8936)
from a Keycloak-owned endpoint. A legacy SSE CAEP profile is supported
for Apple Business Manager (ABM) / Apple School Manager (ASM) compatibility. The ABM/ASM support
enables to use Keycloak as an identity provider for Apple Accounts.
Three system pieces work together:
- Event path.
SsfTransmitterEventListenerpicks up Keycloak events,SecurityEventTokenMapperbuilds the typed SET, the dispatcher applies subscription / status / subject gates and hands the signed SET to the outbox. - Durable outbox (
SSF_PENDING_EVENT). Every SET is persisted before the user's request returns. A single JPA table serves both PUSH and POLL. For PUSH, a cluster-aware drainer pulls due rows, sends them with exponential backoff, and moves them toDELIVEREDorDEAD_LETTER. For POLL, receivers pull rows from the Keycloak-owned poll endpoint and ack them. Row claims on both paths useSELECT … FOR UPDATE SKIP LOCKED(via Hibernate'sLockMode.UPGRADE_SKIPLOCKED) so that (a) the singleton drainer's concurrent worker threads grab disjoint batches without blocking, and (b) multiple receiver pods polling the same stream get disjoint slices of POLL rows instead of duplicates — the DB itself serves as the work-distribution mechanism, no extra coordination layer needed. See Cluster-safe delivery. - Management surface. Receivers manage their own stream via the
SSF REST API (
/streams,/streams/status,/status, verification, well-known metadata). Administrators manage receivers via the Keycloak admin UI's SSF client sub-tab (streams, subjects, pending events, ad-hoc event emission), and tune transmitter-wide behavior via thessf-transmitterSPI category.
Why the outbox (one-line version): so user-facing requests never wait on receiver endpoints, so a restart mid-dispatch can't lose an event, and so retries / rate-limiting / backoff never push back on the event producer. The full rationale and the cluster-safety argument are in Why an outbox and Cluster safety.
Current status: experimental. Functionality targets conformance
with SSF 1.0 Final and the CAEP Interop Profile 1.0 draft; the
feature is hidden behind Profile.Feature.SSF and intended for
early-adopter validation. See Readiness for the gap
analysis.
Two upstream input paths converge on the same mapper / dispatcher / outbox pipeline: native Keycloak events observed by the event listener, and synthetic events posted to the event emitter endpoint by an admin or a trusted external system (LDAP-change notifier, MDM, risk engine, …). Receivers can't tell the two apart on the wire — the SET shape is identical.
┌──────────────┐ Keycloak event ┌─────────────────────┐ POST .../events/emit
│ User action │──────────────────┐ │ External / admin │──────────────┐
│ (logout, │ (LOGOUT, │ │ caller │ │
│ password │ UPDATE_ │ │ (admin UI, LDAP │ │
│ change, …) │ CREDENTIAL, …) │ │ notifier, MDM, │ │
└──────────────┘ │ │ risk engine, │ │
│ │ trusted svc-acct) │ │
│ └─────────────────────┘ │
▼ ▼
┌─────────────────────────────┐ ┌──────────────────────────┐
│ SsfTransmitterEventListener │ │ EventEmitterService │
│ — global, all realms │ │ — caller may manage this │
│ — observes native events │ │ client OR trusted- │
└──────────────┬──────────────┘ │ emitter SVA gated on │
│ │ emitEventsRole │
│ └──────────────┬───────────┘
└─────────────┬─────────────────────────┘
▼
┌──────────────────────────────────┐
│ SecurityEventTokenMapper │
│ — generateFromKeycloakEvent / │
│ generateSyntheticEvent │
│ — builds the typed SsfSET │
│ — resolves sub_id per │
│ receiver userSubjectFormat │
└──────────────┬───────────────────┘
▼
┌──────────────────────────────────┐
│ SecurityEventTokenDispatcher │
│ — applies gates (status / │
│ event-type / subject) │
│ — narrows for SSE_CAEP │
│ profile if needed │
│ — signs via │
│ SecurityEventTokenEncoder │
└──────────────┬───────────────────┘
│
┌──────────┴──────────┐
▼ ▼
┌────────────────┐ ┌────────────────┐
│ PUSH outbox │ │ POLL outbox │
│ (SSF_PENDING_ │ │ (same table, │
│ EVENT, │ │ delivery_ │
│ delivery_ │ │ method=POLL) │
│ method=PUSH) │ │ │
└───────┬────────┘ └───────┬────────┘
▼ ▼
┌──────────────────────────┐ ┌─────────────────────────────┐
│ SsfPushOutboxDrainerTask │ │ SsfStreamPollResource │
│ — cluster-aware tick │ │ — receiver pulls pending │
│ — PushDeliveryService │ │ rows, acks / NACKs them │
│ — exponential backoff │ └─────────────────────────────┘
│ — DEAD_LETTER after │
│ attempt budget │
└──────────────────────────┘
SSF delivery has real-world constraints the request path can't satisfy:
- Durability across restarts and failover. A
LOGOUTevent can't be lost because the transmitter was restarted mid-dispatch. Signed SETs persist inSSF_PENDING_EVENTbefore the Keycloak request returns, so any crash before delivery leaves a recoverable row. - Retries without blocking user requests. The user who triggered the event doesn't wait on the receiver's endpoint. The dispatcher writes the row and returns; the drainer handles retries with exponential backoff on its own schedule.
- Delivery decoupling. Receivers may be slow, down, or rate-limited. Pushing synchronously from the event listener would turn every user event into a cross-network-call-sized latency for the user. The outbox hides that latency inside a background drainer.
- Unified PUSH/POLL storage. Same table serves both delivery
methods, distinguished by the
DELIVERY_METHODcolumn. POLL receivers fetch rows directly; the PUSH drainer processes PUSH rows on its schedule. State-machine transitions (HELD / DEAD_LETTER) apply uniformly to both. - At-least-once semantics with dedup. A
(client_id, jti)unique constraint prevents double-enqueue on retried dispatches. Receivers see each jti at most once per stream (plus any legitimate retries the drainer decides to do after transient failures). - Consistent pause / resume semantics. Because rows are durable, status transitions can act on the queue (discard on disable, hold on pause, release on resume) without losing sync between the stream's status and the in-flight work.
Columns on SsfPendingEventEntity:
| Column | Purpose |
|---|---|
ID |
Primary key (UUID). |
REALM_ID |
Realm the receiver lives in; enables realm-delete cascade. |
CLIENT_ID |
Receiver's internal UUID; the per-receiver queue key. |
STREAM_ID |
Owning stream; lets a stream-delete cascade identify orphaned rows. |
JTI |
The SET's unique JWT id — part of the (client_id, jti) dedup constraint. |
EVENT_TYPE |
Event URI, for admin / debug filtering. |
ENCODED_SET |
The already-signed JWS payload. Kept verbatim so the drainer doesn't re-sign on every retry (the realm key may rotate between attempts). |
DELIVERY_METHOD |
PUSH / POLL — routes the row to the right delivery path. |
STATUS |
PENDING / HELD / DELIVERED / DEAD_LETTER. |
ATTEMPTS, NEXT_ATTEMPT_AT, LAST_ERROR |
Drainer retry state for PUSH. |
CREATED_AT, DELIVERED_AT |
Audit; CREATED_AT also drives per-receiver TTL housekeeping and partition-friendly layout. |
Indexes support the two hot-path queries: drainer claim
(STATUS, DELIVERY_METHOD, NEXT_ATTEMPT_AT) and poll-endpoint read
(CLIENT_ID, STATUS). The unique constraint (CLIENT_ID, JTI, CREATED_AT)
is shaped that way so PostgreSQL / MySQL declarative partitioning by
CREATED_AT is possible — application code never enqueues two rows with
the same (client_id, jti) at different timestamps, so operational
uniqueness is still (client_id, jti).
┌────────────── admin "retry"──────────────┐
│ (reset attempts, │
│ next_attempt_at) │
▼ │
┌──────────┐ ┌─────────────┐
│ PENDING │─────────── retries ────────▶│ DEAD_LETTER │
└────┬─────┘ exhausted └─────────────┘
│
│ push succeeds / POLL ack
▼
┌───────────┐
│ DELIVERED │ (kept 24h — hardcoded
└───────────┘ `SsfPushOutboxDrainerTask.DELIVERED_RETENTION` —
for jti dedup against at-least-once re-enqueues
and short-window audit, then purged by the
drainer's per-tick retention pass. Applies to
both PUSH and POLL since the purge filters on
status, not delivery method.)
┌──────────┐
│ PENDING │ ◀────── stream resumes (status enabled)
└────┬─────┘ ▲
│ stream paused │
▼ │
┌──────────┐ │
│ HELD │────────────────────┘
└────┬─────┘
│ stream transitions to disabled
▼
(deleted — spec-mandated discard on disable)
Keycloak runs HA, so the outbox has to tolerate multiple nodes pulling from the same table simultaneously without duplicating work.
Three mechanisms compose:
ClusterAwareScheduledTaskRunnerwraps the outbox drainer at scheduling time, so only one cluster node drains on any given tick. Other nodes' scheduled runs no-op. This keeps the drainer's dispatch + housekeeping passes singleton, avoiding contention at the coarse level.UPGRADE_SKIPLOCKED(PostgreSQL / MySQLFOR UPDATE SKIP LOCKED, Hibernate'sLockMode.UPGRADE_SKIPLOCKED) on the row-claim queries. Within a single drainer run, the pessimistic-write lock + skip-locked combination lets concurrent workers (or overlapping ticks in edge cases) claim disjoint row sets instead of blocking on each other. On dialects without SKIP LOCKED (H2 in tests), Hibernate falls back to plainFOR UPDATE, which is still correct thanks to the cluster-aware wrapper — serialized cluster- wide, no contention to observe.(CLIENT_ID, JTI)unique constraint as the at-least-once dedup guard. The application-level dedup scan inenqueuePending(...)avoids paying the constraint violation for the common no-op path; the DB-level unique is the safety net when two concurrent dispatchers race.
Where each mechanism applies:
| Operation | Concurrency control |
|---|---|
| Drainer tick (PUSH dispatch + housekeeping) | ClusterAwareScheduledTaskRunner singleton across the cluster. |
PUSH row claim inside a drainer tick (lockDueForPush) |
UPGRADE_SKIPLOCKED + setMaxResults(batchSize) — one node, multiple workers, disjoint batches. |
POLL row claim (lockPendingForReceiverPoll) |
UPGRADE_SKIPLOCKED — multiple receiver pods polling the same stream get disjoint batches without duplicates. |
Enqueue (enqueuePendingPush / enqueuePendingPoll / enqueueHeldXxx) |
Dedup scan + (CLIENT_ID, JTI) unique constraint. No lock. |
Bulk status transitions (holdPendingForClient, releaseHeldForClient, deleteUndeliveredForClient) |
JPA bulk UPDATE / DELETE scoped to one receiver. Same-receiver concurrent transitions serialize on row locks; disjoint receivers don't contend. |
Per-receiver TTL purge (purgeStaleForClient) |
Runs only on the drainer's singleton tick — no cross-node concurrency to control. |
Ordering guarantees:
- The drainer orders its claim by
(next_attempt_at ASC, created_at ASC, jti ASC). WhenreleaseHeldForClientflips every HELD row'snext_attempt_atto the samenowat resume time, thecreated_attiebreaker preserves per-Subject-Principal time-of-generation order within the single drainer batch on that tick. - Strict cross-node order isn't guaranteed, by design:
SKIP LOCKEDsplits the claim across drainer workers, and any two workers can push their rows concurrently. Receivers needing strict single-threaded per-subject order should configure their HTTP side to serialize, or rely on the SET'siat/txnclaims to order at the consumer.
The SSF feature lives under the top-level ssf/ directory as a small
multi-module Maven build. The bundled CAEP and OpenID RISC event-type
classes are sub-packages of ssf/core (org.keycloak.ssf.event.caep /
org.keycloak.ssf.event.risc), not separate modules — third-party
event catalogs ship as their own modules registered via
SsfEventProviderFactory.
| Module | Maven artifact | Purpose |
|---|---|---|
ssf/core |
keycloak-ssf-core |
Shared types: SsfEvent hierarchy + SsfEventRegistry, RFC 9493 SubjectId classes, transmitter-metadata DTO, StreamStatus / StreamStatusValue. Bundles the CAEP event classes (CaepSessionRevoked, CaepCredentialChange, CaepDeviceComplianceChange, …) and the legacy OpenID RISC event types needed for SSE CAEP interop, contributed via DefaultSsfEventProviderFactory. No Keycloak-server dependencies — also usable by receiver-side SPIs if they reuse the types. |
ssf/transmitter |
keycloak-ssf-transmitter |
Transmitter provider SPI, stream service, dispatcher, outbox (entity + store + drainer + backoff + JPA wiring), SET mapper + encoder, SSE CAEP narrowing converter, receiver-facing JAX-RS resources, admin DTOs. |
ssf/services |
keycloak-ssf-services |
Admin REST resource (SsfAdminResource), realm-level JAX-RS wiring, well-known provider factory for .well-known/ssf-configuration. |
ssf/tests/base |
keycloak-ssf-tests-base |
Integration tests against the Keycloak test framework. |
Each module is gated on Profile.Feature.SSF — the provider factories,
JPA entity provider, and event-listener factory all return false from
isSupported(...) when the flag is off, so the code compiles into the
distribution but contributes nothing until the profile enables it.
- Signing-key rotation: drainer retries reuse the
ENCODED_SETstored at enqueue time — if we re-signed on retry, a key rotation between the original dispatch and a retry would produce a differentjtior make the receiver's seen-jti dedup lie. Keeping the byte payload stable across retries keeps receiver-side idempotency correct. - Realm / client-removed cascade:
RealmRemovedEventandClientRemovedEventsubmit anSsfOutboxCleanupTaskto thessf-outbox-cleanupExecutorsProviderpool, which drains the orphaned rows in short batched transactions (default 1 000 rows per tx, capped at 10 000 batches = 10M rows before yielding back to the drainer). Inline DELETE would otherwise serialize the entire backlog into the admin's removal transaction — a receiver with 100k+ queued rows could push the tx past its timeout. The event is node-local, so only the originating node schedules work; if that node crashes mid-task, the push drainer's missing-realm/client/stream fast-path dead-letters orphan PUSH rows on its next tick and dead-letter retention cleans them up. Outbox rows are plain columns (no FK) so the explicit cascade is required — no DB-level referential action. - Stream-delete cascade:
DELETE /streams(receiver-initiated) and the admin stream-delete endpoint still calldeleteByClientinline — the queue is bounded by a single receiver's backlog, and POLL rows in particular must be removed synchronously because no consumer will poll them once the stream is gone. - Duplicate
ssf.streamIdrejection:DefaultSsfTransmitterProviderFactory.validateImportedStreamIdhooksClientUpdatedEventand throwsModelDuplicateExceptionwhen the client being saved carries anssf.streamIdthat another client in the same realm already holds. This catches the JSON export/import footgun — cloning a receiver client without strippingssf.streamIdwould otherwise yield two clients sharing a stream id, and the dispatcher'sfindClientByStreamIdpicks one nondeterministically. Delete-then-reimport of the same id stays intact because the validator fires after attribute save, so the original is already gone by the time the reimport's event is observed.ClientStreamStore.findClientByStreamIdalso fails closed (Optional.empty()+ warn) on any multi-match that slips past the validator, so dispatch never silently resolves to the wrong client. - Narrowing at dispatch time, not enqueue time: the dispatcher
narrows for SSE CAEP per-receiver profile before signing, so the
stored
ENCODED_SETalready matches the receiver's negotiated wire shape. The drainer doesn't know or care about profiles.
Not every Keycloak user event is interesting to every receiver. When a Keycloak event is translated into an SSF SET, the dispatcher runs a subject-selection gate before the SET is signed and enqueued: for each stream it asks "is this user a subject this receiver should be notified about?". If the answer is no, the SET is dropped on the floor for that receiver (other receivers are evaluated independently).
The gate combines a transmitter-wide default with per-user / per-organization overrides.
1. Default policy (transmitter-wide, per stream). The realm-level
SSF transmitter configuration carries a default_subjects setting
with two values:
ALL— automatic delivery. Every user in the realm is an implicit subject for every receiver, unless an explicit override says otherwise.NONE— opt-in delivery. A user is not a subject for any receiver unless explicitly subscribed.
The default is also the fallback the dispatcher lands on when no
per-user / per-org marker is present (see below). Receivers can
override the transmitter-wide default on their own stream via the
SSF §8.1.3 subject-management endpoints (POST /realms/{realm}/ssf/transmitter/subjects/add / .../subjects/remove)
and/or via the admin UI's Subjects sub-tab.
2. Per-user override: ssf.notify.<receiverClientId>. Subject
state is stored as a user attribute keyed by the OAuth client_id
of the receiver client (so admins can read / edit it by name in the
admin UI). The attribute is a tri-state: present-and-true,
present-and-false, or absent.
ssf.notify.<receiverClientId>=true— the user is explicitly included as a subject for that receiver. Events about this user flow to this receiver even whendefault_subjects=NONE.ssf.notify.<receiverClientId>=false— the user is explicitly excluded. Events about this user are suppressed for that receiver even whendefault_subjects=ALL.- Attribute absent — defer to the owning organization (step 3), then to the transmitter default (step 4).
3. Organization fallback. If the user's attribute is absent, the
dispatcher looks for the same ssf.notify.<receiverClientId> marker
on the owning organization of the user. This lets an admin flip
an entire org into / out of a receiver's subject set with a single
attribute write, without touching individual users. Same tri-state
semantics as the user attribute: true includes, false excludes,
absent defers further.
4. Fallback: transmitter default. If neither the user nor the
owning org carries a ssf.notify.<receiverClientId> marker, the gate
honors the stream's effective default_subjects setting — ALL
delivers, NONE drops. The effective value is resolved per receiver:
the SSF receiver client can override the realm-wide transmitter
default via its own ssf.defaultSubjects client attribute (surfaced
on the Receiver sub-tab as "Default subjects"), so a transmitter that
is otherwise NONE-by-default can flip a single receiver to ALL,
or vice versa, without changing the realm-wide policy. If the
receiver does not set its own override, the realm-wide
default-subjects from the ssf-transmitter SPI wins.
The resolution precedence is therefore:
user attr present ──▶ use user attr (true|false)
│absent
▼
org attr present ──▶ use org attr (true|false)
│absent
▼
default_subjects ──▶ ALL delivers, NONE drops
This happens at dispatch time, not at enqueue time: the outbox row only gets written once the receiver has been judged eligible. Rows that are already in the outbox do not get re-evaluated when a subject marker later flips — the decision is a snapshot of the state at the moment the event was produced.
Pre-mapper short-circuit (optimization, not a semantic change).
When the native SsfTransmitterEventListener fans out a Keycloak
user event across every SSF receiver stream in the realm, it calls
SubjectSubscriptionFilter.shouldDispatchForUser(user, stream, …)
before invoking the mapper per stream. Streams whose subject
gate already says "no" are skipped without building a SET for them,
so a realm with N receivers and a user subscribed to only one of
them pays the mapper cost once instead of N times. The dispatcher-
side gate still runs as the authoritative check (it receives the
already-built token's resolved subject and handles complex-subject
cases that the pre-gate can't), so a stream that passes the pre-
gate but fails the dispatcher gate is still correctly suppressed —
the pre-gate can never flip a "no" into a "yes".
5. Subject removal grace window (SSF §9.3 defense, opt-in). A
receiver-driven POST /streams/subjects/remove writes a tombstone
attribute (ssf.notifyRemovedAt.<receiverClientId>=<epoch_seconds>)
before clearing the include marker. When the SPI knob
subject-removal-grace-seconds is set to a positive value, the
NONE-mode no-marker fallback (step 4) checks the tombstone and
still delivers the event if now - removedAt < grace_seconds.
This is the SSF §9.3 "Malicious Subject Removal" defense: a
compromised receiver bearer token can't silently silence events for a
target subject during an attack window — events keep flowing for the
configured grace period. The tombstone write is coalesced to
5-minute slots (mirroring SsfActivityTracker) so re-removing the
same subject inside the slot doesn't trash the user / org cache with
redundant attribute writes.
Important trade-offs:
- Default is
0(off) — current behavior. Operators who enable the grace are knowingly accepting that legitimate receiver-driven removes (e.g. churn cleanup when a user leaves the receiver's service) also get the grace tail. There is no per-remove opt-out — receivers can't bypass the grace, otherwise the protection would be defeated by exactly the threat it's meant to block. - Admin-driven removes always skip the tombstone — operator
actions through the admin UI /
POST /admin/.../subjects/removeare trusted and take effect immediately. The §9.3 protection only applies to the receiver-driven path. - Re-add or admin-ignore clears the tombstone — re-subscribing trumps the grace; admin-marking-as-ignored explicitly excludes with no grace tail.
- ALL-mode is unaffected. ALL means "deliver to everyone unless excluded"; receiver-driven removes don't flip delivery off in ALL mode anyway, so the tombstone is moot there.
Every SsfEventProviderFactory contributes its event types to a single
process-wide SsfEventRegistry at server startup. The registry exposes
two derived sets that drive both the dispatcher gate and the admin UI:
- Available (receiver-requestable) events —
registry.getReceiverRequestableEventTypes(): every registered event type except the protocol-internal lifecycle types (stream-verification,stream-updated). These two are owned by the transmitter end-to-end and external callers (admin, synthetic emit, receivers viaevents_requested) are intentionally not allowed to forge them — letting an external caller fire astream-updatedSET would let it spoof transmitter behaviour towards the receiver. This is the set the admin UI's Receiver sub-tab shows in the "Supported events" multi-select, and the set the synthetic emit endpoint accepts. - Built-in (natively emitted) events —
registry.getEmittableEventTypes(): the subset of available events that some registeredSsfEventProviderFactorydeclares as natively emitted (i.e. Keycloak fires them automatically from native event listeners — credential change, session revoked, …). This is informational metadata, not a delivery gate: the admin UI badges these entries with "built-in" so operators understand which event types fire automatically vs which require the synthetic emit endpoint or a custom mapper, but the synthetic emit endpoint can fire any event in the available set, including ones outside the built-in subset.
Custom event types contributed by extensions appear automatically in both views — the registry has no allow-list, every factory's contributions count.
Not every security-relevant signal Keycloak should forward to
downstream SSF receivers is born as a Keycloak event. Some signals
happen outside Keycloak entirely — e.g. a password changed in an
LDAP / Active Directory server that Keycloak federates against, a
compliance-posture change reported by an MDM system, a
fraud-detection signal raised by a risk engine sitting next to
Keycloak — and some SSF / CAEP event types (like
device-compliance-change) have no corresponding Keycloak event at
all because they describe a domain Keycloak itself does not observe.
To keep SSF useful in those scenarios, the transmitter exposes a synthetic event emitter that lets an authorized caller produce an SSF SET over REST as if Keycloak had observed the underlying event itself. The caller supplies the event type, the subject (by user-id / email / username / org-alias), and the event payload JSON; the transmitter builds the typed SET, signs it, applies the same subject-selection / stream-status gates as a native event, and hands it to the outbox for delivery. Receivers see a normal signed SET on the wire — they cannot tell whether the signal originated from a real Keycloak event or from a synthetic emit.
There are two authorized call paths:
- Admin emit — callers that have permission to manage the
receiver client via
POST /admin/realms/{realm}/ssf/clients/{clientId}/events/emit, surfaced in the admin UI under the receiver's Pending Events sub-tab. Intended for operational use (testing, incident-driven ad-hoc emits, replaying a missed signal). The bypass is keyed offauth.clients().canManage(receiver)— that includes the realm-widemanage-clientsrole and fine-grained per-client manage permission (Authorization-based) when the realm has fine-grained admin permissions enabled. - Trusted-emitter service account — gated per receiver by
ssf.allowEmitEvents=true+ anssf.emitEventsRolerole that the caller's service account must hold. Intended for programmatic integration: an external system (LDAP-change notifier, MDM, risk engine, custom workflow) posts to the same emit endpoint using its own OAuth credentials and pushes the event into Keycloak's SSF pipeline without impersonating an admin. Misconfiguration is refused: whenallowEmitEvents=truebut no role is configured, the endpoint returns403 emit_role_not_configuredrather than silently accepting any service account — there's no implicit fallback to "any caller is fine."
What can be emitted: any event type registered via
SsfEventProviderFactory other than the stream-internal lifecycle
types (verification SET, stream-updated SET — see
Event registry). The synthetic
emit endpoint is not restricted to the events Keycloak natively
fires from event listeners; an external system may legitimately produce
event types Keycloak itself never observes (device-compliance-change,
assurance-level-change, anything contributed by a custom event
provider). The receiver's ssf.supportedEvents selection on the
Receiver sub-tab is the practical gate for what a given receiver
will then accept — the available-events list there shows the same
registry-minus-lifecycle set, with a "built-in" badge marking the
natively-fired subset.
This turns the synthetic emitter into a first-class extension seam:
deployments can make Keycloak the SSF delivery hub for their entire
identity ecosystem, not just for events Keycloak directly observes.
The CAEP device-compliance-change event type is the canonical
example — there's no Keycloak event that fits, but an MDM integration
can still propagate it to downstream SSF receivers via emit.
End-to-end shape for a service-account caller firing a CAEP session
revoked event for a single user. Assumes the realm is myrealm, the
SSF receiver is caep-dev-receiver, and an emitter client
ldap-sync has been wired up: caep-dev-receiver carries
ssf.allowEmitEvents=true and ssf.emitEventsRole=caep-dev-receiver.ssf-event-emitter,
the matching client role exists on caep-dev-receiver, and the
service account of ldap-sync has been granted that role.
- Obtain a service-account access token for the emitter client:
ACCESS_TOKEN=$(curl -s -X POST \
"https://kc.example.com/realms/myrealm/protocol/openid-connect/token" \
-u "ldap-sync:$LDAP_SYNC_CLIENT_SECRET" \
-d "grant_type=client_credentials" \
| jq -r .access_token)- Emit the event. The trusted-emitter path requires the RFC 9493
sub_idform verbatim (the admin-onlysubjectType/subjectValueshorthand is not honored here). For a session-revoke covering both the user and a specific session, send acomplexsubject withuserandsessionfacets:
curl -X POST \
"https://kc.example.com/admin/realms/myrealm/ssf/clients/caep-dev-receiver/events/emit" \
-H "Authorization: Bearer ${ACCESS_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"eventType": "CaepSessionRevoked",
"sub_id": {
"format": "iss_sub",
"iss": "https://kc.example.com/realms/myrealm", "sub": "<user-uuid>"
},
"event": {
"event_timestamp": 1713360000,
"reason_admin": { "en": "Revoked by upstream IdP after device wipe" }
}
}'A successful call returns 200 with {"status": "dispatched", "jti": "..."} —
the SET is signed and handed to the outbox, then pushed (or made
available for POLL) on the receiver's configured stream.
Useful response codes to know about (full matrix in the admin endpoints table):
403 emit_not_allowed— receiver has not setssf.allowEmitEvents=true.403 emit_role_not_configured—ssf.emitEventsRoleis empty (refused on purpose, no implicit fallback).403 emit_role_missing— caller's service account doesn't hold the configured role.403 not_service_account— token came from a user-delegated grant (e.g. password grant) rather than client credentials.404— receiver client not found or has no SSF stream registered.
Three extension points let a deployment add its own SSF event type
without touching transmitter code: an SsfEvent subclass for the
typed payload, an SsfEventProviderFactory to register it with the
shared registry, and either the synthetic-emit endpoint or the
in-process eventEmitterService() accessor to actually fire it. A
fourth (custom Keycloak event listener) lets a deployment translate
native Keycloak events into the new SSF event type.
Extend SsfEvent directly (or CaepEvent / RiscEvent for
profile-correctness), declare a TYPE constant for the URI, and add
typed @JsonProperty fields. The alias defaults to the simple class
name; override setAlias(...) in the constructor if you want a
different one.
package com.example.ssf;
import com.fasterxml.jackson.annotation.JsonProperty;
import org.keycloak.ssf.event.SsfEvent;
public class MdmDeviceQuarantined extends SsfEvent {
public static final String TYPE = "https://schemas.example.com/secevent/mdm/event-type/device-quarantined";
@JsonProperty("device_id") private String deviceId;
@JsonProperty("policy_name") private String policyName;
@JsonProperty("severity") private String severity;
public MdmDeviceQuarantined() {
super(TYPE);
// Defaults to "MdmDeviceQuarantined" from the class name; override if
// you want a shorter alias:
// setAlias("DeviceQuarantined");
}
// getters / setters …
}Implement SsfEventProviderFactory — no need to subclass
DefaultSsfEventProviderFactory, it's already registered, you just
add a second factory. With the default create(KeycloakSession)
method baked into the SPI, contribution-only factories collapse to
getId() + isSupported() + the getContributedEventFactories()
map.
package com.example.ssf;
import java.util.Map;
import java.util.Set;
import java.util.function.Supplier;
import org.keycloak.Config;
import org.keycloak.common.Profile;
import org.keycloak.ssf.event.SsfEvent;
import org.keycloak.ssf.event.SsfEventProviderFactory;
public class MdmEventProviderFactory implements SsfEventProviderFactory {
@Override public String getId() { return "mdm-events"; }
@Override public boolean isSupported(Config.Scope c) { return Profile.isFeatureEnabled(Profile.Feature.SSF); }
@Override
public Map<String, Supplier<? extends SsfEvent>> getContributedEventFactories() {
return Map.of(MdmDeviceQuarantined.TYPE, MdmDeviceQuarantined::new);
}
/**
* Subset Keycloak (or this extension) actually fires natively. Drives
* the admin-UI "built-in" badge — informational, NOT a delivery gate.
* If the event only ever flows through synthetic emit, leave this empty.
*/
@Override
public Set<String> getEmittableEventTypes() {
return Set.of(MdmDeviceQuarantined.TYPE);
}
}Wire it via META-INF/services/org.keycloak.ssf.event.SsfEventProviderFactory:
com.example.ssf.MdmEventProviderFactory
The registry auto-aggregates every factory's contributions at server
startup. The new alias appears in the admin-UI multi-selects and is
accepted by events_requested on stream-create; no further plumbing.
import org.keycloak.models.ClientModel;
import org.keycloak.models.RealmModel;
import org.keycloak.ssf.subject.IssuerSubjectId;
import org.keycloak.ssf.subject.SubjectId;
import org.keycloak.ssf.transmitter.SsfTransmitter;
import org.keycloak.ssf.transmitter.emit.EmitEventResult;
RealmModel realm = session.getContext().getRealm();
// One-liner lookup + ssf.enabled=true validation. Throws SsfException
// with a clear message when the clientId is unknown OR points at a
// non-SSF client (regular OIDC app, service account, …).
ClientModel receiver = SsfTransmitter.getReceiverClient(session, "caep-dev-receiver");
SubjectId subjectId = new IssuerSubjectId();
subjectId.setIssuer(realm.getBaseUrl()); // e.g. https://kc.example.com/realms/myrealm
subjectId.setSub(user.getId());
EmitEventResult result = SsfTransmitter.of(session)
.eventEmitterService()
.emit(receiver,
MdmDeviceQuarantined.TYPE, // or the alias "MdmDeviceQuarantined"
subjectId,
Map.of("device_id", "abc-123",
"policy_name", "no-jailbreak",
"severity", "high"));
// result.status() distinguishes DISPATCHED from the various dropped cases
// (events_requested filter, subject filter, etc.). DISPATCHED means the
// SET is signed and in the outbox; the drainer pushes asynchronously.Same dispatch / outbox / push pipeline runs as for native events.
Receiver-side filters (events_requested, subject subscription) still
apply — result.status() tells you which gate dropped it. Stream-
internal lifecycle types (stream-verification, stream-updated) are
rejected as EVENT_TYPE_NOT_EMITTABLE so an extension can't forge
transmitter behaviour. For yes/no inspection (e.g. when iterating a
list of clients), use SsfTransmitter.isReceiverClient(client)
instead of letting getReceiverClient throw.
Two paths, pick by ergonomics.
(a) Custom EventListenerProvider — recommended. Observe Keycloak
events in your own listener and call eventEmitterService().emit(...)
from there. No touching SSF internals; same KeycloakSession injection
and lifecycle as Keycloak's other event listeners. Best when "Keycloak
event X → my SSF event Y" is the only mapping rule.
public class MdmEventListener implements EventListenerProvider {
private final KeycloakSession session;
@Override
public void onEvent(Event event) {
if (event.getType() != EventType.UPDATE_PROFILE) return;
if (!"jailbroken".equals(event.getDetails().get("compliance_state"))) return;
// resolve receiver + subject, then emit
SsfTransmitter.of(session).eventEmitterService().emit(/* … */);
}
}Register via the normal EventListenerProviderFactory SPI.
(b) Override SecurityEventTokenMapper via
SsfTransmitterServiceBuilder#createMapper. Heavier, but plugs the
mapping into the same in-process path the bundled CAEP mappers use —
so the custom event participates in subject-format resolution, SSE-CAEP
narrowing, etc. Worth doing if the extension wants to replace a
native CAEP mapping for some Keycloak event, or maps many event types
and prefers one place to host the rules.
- OpenID Shared Signals Framework 1.0 (Final) — stream management, subject management, verification, delivery negotiation, metadata.
- RFC 8935 — SET delivery via HTTP PUSH.
- RFC 8936 — SET delivery via HTTP POLL.
- RFC 9493 — Subject Identifiers (
iss_sub,email,opaque,complexwithuser/session/tenantfacets). - CAEP Interoperability Profile 1.0 (draft) —
session-revokedandcredential-changeevent types with accuratechange_type. - Legacy SSE CAEP profile for Apple Business Manager / Apple School
Manager interop (gated behind
sse-caep-enabledSPI flag).
/.well-known/ssf-configurationpublished per realm.- Advertises
issuer,jwks_uri,delivery_methods_supported(PUSH + POLL, plus legacy RISC URIs when SSE CAEP is enabled),configuration_endpoint,status_endpoint,verification_endpoint,add_subject_endpoint/remove_subject_endpoint(when subject management is enabled),authorization_schemes(OAuth 2.0),default_subjects,critical_subject_members(default["user"]),spec_version.
- Create / read / update (PATCH merge, PUT replace) / delete a stream per receiver client.
- Transmitter-controlled fields (
stream_id,iss,aud,events_supported,events_delivered,kc_*extensions) are stamped by the server and rejected with 400 if a receiver tries to supply them. kc_*admin-only fields (kc_status,kc_status_reason,kc_created_at,kc_updated_at) areWRITE_ONLY— never leak on the receiver-facing wire.- Multi-stream per receiver is intentionally disabled (409 on second create) — design choice for v1.
- GET / POST
/streams/statuswithenabled/paused/disabled. paused: in-flight PENDING outbox rows transition to HELD; new events from the dispatcher enqueue as HELD instead of being pushed.disabled: all PENDING + HELD rows for the receiver are deleted per the spec's MUST-NOT-hold rule. Re-enabling starts from empty.enabledfrom paused/disabled: HELD rows released back to PENDING (withnext_attempt_at = now) so PUSH resumes.stream-updatedSET dispatched on every status transition (including inactivity-driven pauses — spec carves this out as a MUST).
- POST
/subjects:add//subjects:remove(receiver-facing). - Silent 200 response for unknown subjects — privacy-by-default, no existence oracle.
- Subject-subscription filter in the dispatcher consults the
ssf.notify.<clientId>attribute on the resolved user / organization. default_subjectspolicy:ALLdelivers unless explicitly excluded;NONEdelivers only when explicitly included.- Admin-only
/subjects/ignorepath for marking a subject as NOT notified (distinct from "remove from subscription list").
- All verification dispatches funnel through a single
StreamVerificationService.triggerVerification(request, initiator)call so the rate-limit stamp (ssf.lastVerifiedAt) and the Prometheus signal (keycloak_ssf_verification_requests_total) stay consistent regardless of who triggered the dispatch. - Three entry points, distinguished on the metrics side by the
initiatorlabel:receiver—POST /realms/{realm}/ssf/transmitter/verify, rate-limited per receiver viamin_verification_interval. Returns429 Too Many Requestswhen called too frequently; the rate-limit rejection is also recorded as a verification request withoutcome="rate_limited"so over-polling is observable without grepping logs.admin— admin Verify button / RESTPOST /admin/realms/{realm}/ssf/clients/{clientId}/stream/verify(gated onauth.clients().requireManage(receiverClient)— broadmanage-clientsrole or fine-grained per-client manage permission); skips the receiver-side rate limit so operators can re-verify on demand.transmitter— auto-fire shortly after stream create, with a configurable delay (transmitter-initiated-verification-delay-millis). Per SSF §8.1.4.2-5 the request MUST NOT carry a state nonce, so the transmitter-initiated path leaves it unset.
- Dispatch is synchronous through
dispatcher.deliverEventSync— the caller (admin button / receiver / post-create auto-fire) needs the receiver's actual accept/reject outcome inline rather than "we scheduled it on the executor". - Verification SET carries an opaque
sub_id=stream_idper spec. - The
ssf.lastVerifiedAtclient attribute is stamped only for explicitly-requested verifications (receiverandadmininitiators) — even on delivery failure, because the rate-limit semantics are "when did the admin/receiver last ask for this". Thetransmitter-initiated post-create auto-fire deliberately does not stamp, so a freshly-created stream's first receiver- initiatedPOST /streams/verifyis not rejected with 429 because of the auto-fire that happened milliseconds earlier. Consequence: the admin UI's "last verified" field reflects only caller-driven verifications; the post-create dispatch is only visible via thekeycloak_ssf_verification_requests_total{initiator="transmitter"}counter.
- Durable outbox (JPA-backed
SSF_PENDING_EVENT) with cluster-safe drainer (UPGRADE_SKIPLOCKED) wrapped inClusterAwareScheduledTaskRunnerso one node drains per tick. - Exponential backoff with configurable retry budget, DEAD_LETTER terminal state after exhaustion.
- Accepts any 2xx response from the receiver as success.
- Per-subject time-order tiebreaker in the drainer claim:
ORDER BY next_attempt_at, created_at, jti. - Per-receiver push endpoint connect / socket timeouts configurable.
- Endpoint URL transmitter-owned:
/receivers/{clientId}/streams/{streamId}/poll. - Request shape:
maxEvents,ack,setErrs; response carriessetsandmoreAvailable. setErrs(NACK) transitions matched rows to DEAD_LETTER.- Batch caps on
ackandsetErrs(1000 each). - Error response shape per RFC 8936 §2.4.4:
{err, description}. - Long-polling (
returnImmediately/maxWait): parsed but not honored — deferred for a future PR.
LOGOUT→CaepSessionRevokedwith complex subject (user+session), optionalreason_admin.UPDATE_CREDENTIAL→CaepCredentialChangewithchange_type=UPDATE.REMOVE_CREDENTIAL→CaepCredentialChangewithchange_type=DELETE.RESET_PASSWORD→CaepCredentialChangewithchange_type=UPDATE,credential_type=password.- Deprecated clones (
UPDATE_PASSWORD,UPDATE_TOTP,REMOVE_TOTP) deliberately skipped to avoid double emission.
iss_sub(default) — realm issuer + user UUID.email— fails loud when the user has no email.complex.iss_sub+tenant/complex.email+tenant— wrap the user subject in aComplexSubjectIdand add atenantsibling carrying the user's first organization (managed-preferred policy — MANAGED membership wins, else first UNMANAGED).
- Global
outbox-dead-letter-retention(default 30d) for DEAD_LETTER rows. - Per-receiver
ssf.maxEventAgeSeconds— purges any non-DELIVERED row older than the cutoff before the global retention applies. Runs in the drainer's housekeeping pass. - Per-receiver
ssf.inactivityTimeoutSeconds— auto-pauses streams whose receiver hasn't touched the transmitter within the window. Activity is tracked at 5-minute coalesced granularity to avoid hammering the client-attribute store on busy pollers.
- Per-realm SSF config endpoint for the admin UI.
- Per-receiver CRUD on streams (mirrors receiver flow but trusted).
- Admin verification trigger.
- Admin subject add / remove / ignore.
- Admin synthetic event emit (bypass for callers with permission to manage the receiver client; otherwise gated on the trusted-emitter role configured per receiver).
- Pending-event lookup by jti (returns decoded SET + resolved user UUID).
Administrators interact with SSF at two scopes: the realm (master switch + realm-wide defaults) and the SSF-enabled client (one receiver's full configuration, stream, subjects, and outbox).
- Transmitter enable flag — a per-realm master switch stored as the
realm attribute
ssf.transmitterEnabled. With the server-wideProfile.Feature.SSFflag on, realms remain dark until an admin flips this toggle on. Turning it off disables event production, admin APIs, and the receiver-facing endpoints for that realm without affecting other realms on the same server. - Realm-level SSF defaults — transmitter defaults that apply to
every receiver in the realm unless the receiver overrides them on
its own SSF tab: default signature algorithm, default user-subject
format,
default_subjectspolicy (ALL / NONE), minimum verification interval, push endpoint HTTP timeouts. Surfaced via the admin REST endpointGET /admin/realms/{realm}/ssf/config.
An OAuth / OIDC client becomes an SSF receiver by having the
ssf.enabled=true client attribute (wired from an "SSF enabled"
toggle on the client's SSF tab). Once enabled, an SSF tab
appears in the client editor with four sub-tabs:
- Receiver — the receiver's configuration / policy knobs. Profile
(
SSF_1_0/SSE_CAEP), stream audience override, per-receiver default subjects, user subject format, verification trigger (receiver-initiated vs transmitter-initiated), minimum verification interval, required role / service account, auto-notify-on-login, allow synthetic events + emit-events-role, max event age, inactivity timeout. The "Supported events" multi-select shows every registered (non-lifecycle) event type — entries Keycloak fires from native event listeners get a "built-in" badge so operators can tell at a glance which selections deliver automatically vs which only fire when an external system uses the synthetic emit endpoint or a custom mapper is shipped. Each knob is a client attribute under thessf.*namespace (see Client attributes). - Stream — streams management for this receiver. Shows the
single stream's current state (
enabled/paused/disabled, status reason), the delivery method picker (PUSH ↔ POLL), the receiver's push endpoint URL (for PUSH) or a copy-to-clipboard of the transmitter-owned poll URL (for POLL), and admin actions to create / verify / delete the stream. Delivery-method flips and status transitions respect the same outbox state-machine rules the REST API follows (migrate queued rows on method change, hold-on-paused, discard-on-disabled). - Subjects — subject management for the stream. Add, remove, or
ignore (explicit-exclude) a user or an organization as a
subscription target. Reads / writes the
ssf.notify.<receiverClientId>attribute described in Subject selection. A "check" action surfaces what the gate would decide for a given user right now, so admins can verify default / override / org precedence without emitting a real event. - Pending Events — a lens into the outbox for this receiver.
Lookup by
jtireturns the full decoded SET plus the resolved user UUID and delivery metadata (status, attempts, last error, next attempt at). An emit form lets the admin synthesize a SET on demand with a subject-type shorthand (user-email/user-id/user-username/org-alias) and a CodeEditor-backed JSON payload with live validation; the__now__placeholder is expanded client-side by the admin UI to the current Unix time before the payload is submitted, so event timestamps can be expressed declaratively. Admin emit bypasses the receiver'sallowEmitEventsopt-in (trusted- emitter service accounts still gate onemitEventsRole).
SSF plugs into a handful of existing Keycloak SPIs and introduces two
new ones of its own. Everything listed here is gated on
Profile.Feature.SSF — the factories return isSupported() == false
when the feature flag is off, so the code is present in the
distribution but dark until the profile enables it.
| SPI (category) | Contract | Default implementation | Purpose |
|---|---|---|---|
ssf-transmitter |
SsfTransmitterProviderFactory → SsfTransmitterProvider |
DefaultSsfTransmitterProviderFactory / DefaultSsfTransmitterProvider |
Central provider for the transmitter: exposes the stream service, subject manager, dispatcher, verification service, outbox wiring, and the SsfTransmitterConfig knobs. All transmitter-wide behavior (signing alg, default subject format, timeouts, …) is configured on this SPI's factory. |
ssf-event |
SsfEventProviderFactory → SsfEventProvider |
DefaultSsfEventProviderFactory + per-profile event-provider modules (ssf.event-providers.caep, ssf.event-providers.openid-risc) |
Extensibility point for additional SSF event catalogs. Each provider contributes event-type classes to the SsfEventRegistry at startup. Out of the box: CAEP (CaepSessionRevoked, CaepCredentialChange, CaepAssuranceLevelChange, …) and the legacy OpenID RISC types needed for SSE CAEP interop. Third parties can ship their own event module without touching transmitter code. |
| SPI | Implementation class | Role in SSF |
|---|---|---|
EventListenerProviderFactory |
SsfTransmitterEventListenerFactory → SsfTransmitterEventListener |
Observes realm/user/session/credential events from Keycloak and funnels them into SecurityEventTokenMapper so they can be translated to SSF SETs. Registered as global (isGlobal() == true) — it is active for every realm automatically and intentionally does not appear in the admin UI's per-realm event-listener picker, so admins can't accidentally disable SSF event production by editing the realm's "Events" config. The feature flag + the realm-level transmitter toggle remain the master switches. |
WellKnownProviderFactory |
SsfTransmitterMetadataWellKnownProviderFactory (SSF 1.0, id ssf-configuration) and SseTransmitterMetadataWellKnownProviderFactory (legacy SSE, id sse-configuration) |
Publishes the transmitter metadata (issuer, jwks_uri, delivery_methods_supported, endpoint URIs, …) at .well-known/ssf-configuration. The legacy sse-configuration endpoint serves a subset of the same document for pre-SSF 1.0 receivers that only understand the older metadata shape, and is disableable via spi-wellknown--sse-configuration--enabled=false. Independently of discovery, a receiver's SET wire format is controlled per client via the ssf.profile attribute (SSF_1_0 vs SSE_CAEP). |
RealmResourceProviderFactory |
SsfRealmResourceProviderFactory → SsfRealmResourceProvider |
Hosts the receiver-facing JAX-RS tree at /realms/{realm}/ssf/... — stream management, status, subjects, verification, and the POLL endpoint. |
AdminRealmResourceProviderFactory |
SsfAdminRealmResourceProviderFactory → SsfAdminRealmResourceProvider |
Hosts the admin-facing JAX-RS tree at /admin/realms/{realm}/ssf/... — realm-level SSF config, admin stream / subject / pending-event operations, synthetic event emit. |
JpaEntityProvider |
SsfJpaEntityProviderFactory → SsfJpaEntityProvider |
Registers the SsfPendingEventEntity JPA entity and ships the Liquibase changelog that creates SSF_PENDING_EVENT + its indexes. |
TimerProvider + ClusterAwareScheduledTaskRunner |
SsfPushOutboxDrainerTask (implements Keycloak's ScheduledTask) |
Cluster-singleton scheduled task for draining the PUSH outbox and running retention housekeeping. Scheduled at transmitter startup via TimerProvider#scheduleTask. |
ClientProvider / RealmProvider / UserProvider / OrganizationProvider |
consumed via KeycloakSession |
Receiver client lookups, realm context, user resolution for subject formats, organization fallback in subject selection. Used only as consumers — no new provider impl. |
JpaConnectionProvider |
consumed via KeycloakSession |
Ambient EntityManager for the outbox store (SsfPendingEventStore). Transactions come from the request / scheduled-task tx boundary; the store never opens its own. |
The SSF modules ship the corresponding META-INF/services/... service
files so Keycloak's provider discovery picks up all the factories
listed above without an extra registration step — the usual SPI
lookup mechanism is the only integration point.
| Property | Default | Purpose |
|---|---|---|
push-endpoint-connect-timeout-millis |
1000 | HTTP connect timeout for outbound push delivery. |
push-endpoint-socket-timeout-millis |
1000 | HTTP socket timeout for outbound push delivery. |
transmitter-initiated-verification-delay-millis |
1500 | Delay before auto-firing the verification SET after stream create. |
min-verification-interval-seconds |
60 | Default receiver-initiated verification rate limit. |
signature-algorithm |
RS256 |
JWS algorithm used to sign SETs. |
user-subject-format |
iss_sub |
Default user-subject shape. |
default-subjects |
NONE |
Default subscription policy: ALL / NONE. |
subject-management-enabled |
true |
Exposes /subjects:add / /subjects:remove. |
sse-caep-enabled |
true |
Advertises the legacy RISC delivery URIs for Apple-style receivers. |
critical-subject-members |
user |
Comma-separated set of critical complex-subject member keys (empty ⇒ omit from metadata). |
outbox-dead-letter-retention |
30d | How long DEAD_LETTER rows are retained before global purge. |
metrics-enabled |
true |
Installs the SSF Prometheus metrics binder. When false, dispatcher / drainer / poll endpoint route through a NOOP binder and the per-tick outbox-depth aggregate is skipped. See Metrics. |
subject-removal-grace-seconds |
0 |
SSF §9.3 grace window — keep delivering events for a subject for this many seconds after a receiver-driven subjects/remove. Admin removes always immediate. 0 disables the protection entirely. See Subject selection, step 5. |
When the Keycloak /metrics endpoint is enabled (--metrics-enabled=true)
and the SSF SPI flag metrics-enabled is on (the default), the SSF
transmitter contributes a small set of Prometheus meters under the
keycloak.ssf.* prefix (rendered as keycloak_ssf_* on the wire,
following Prometheus naming conventions). Counters / timers increment
on the dispatcher / drainer / poll hot paths; the outbox-depth gauge
reads from a snapshot refreshed once per drainer tick so scrapes never
hit the database.
| Meter | Type | Labels | Source |
|---|---|---|---|
keycloak_ssf_events_enqueued_total |
counter | realm, client_id, delivery_method (PUSH/POLL), event_type (alias) |
Dispatcher, after a SET is persisted to the outbox for wire delivery. |
keycloak_ssf_events_suppressed_total |
counter | realm, client_id, reason (status_disabled / event_not_requested / subject_gate / status_paused_held) |
Dispatcher, when an event is dropped or held instead of delivered. |
keycloak_ssf_push_delivery_total |
counter | realm, client_id, outcome (delivered / retry / dead_letter / orphaned) |
Drainer, after each PUSH outbox row is processed. |
keycloak_ssf_push_delivery_duration_seconds |
timer | realm, client_id, outcome |
Drainer, end-to-end per-row push duration (HTTP send + outbox transition). |
keycloak_ssf_poll_served_total |
counter | realm, client_id |
Poll endpoint, total rows handed back to receivers. |
keycloak_ssf_poll_ack_total |
counter | realm, client_id |
Poll endpoint, ack entries processed. |
keycloak_ssf_poll_nack_total |
counter | realm, client_id |
Poll endpoint, setErrs entries processed. |
keycloak_ssf_drainer_tick_total |
counter | outcome (ok / error) |
Drainer, one increment per tick — cluster-aggregate (no node label). |
keycloak_ssf_drainer_tick_duration_seconds |
timer | — | Drainer, full tick duration. |
keycloak_ssf_drainer_tick_last_at_seconds |
gauge | — | Epoch-second of the most recent drainer tick attempt. Stamped on both ok and error outcomes — only a stuck tick that never returns lets the value fall behind. Reports 0 until the first tick observed, so alerting rules should ignore the gauge until it has been non-zero at least once. Drives stall detection via time() - keycloak_ssf_drainer_tick_last_at_seconds > N (complements the counter-rate-based check). |
keycloak_ssf_verification_requests_total |
counter | realm, client_id, initiator (receiver / admin / transmitter), outcome (delivered / failed / rate_limited) |
StreamVerificationService, one increment per verification dispatch. rate_limited is fired in the receiver-facing resource before the dispatch happens. |
keycloak_ssf_verification_duration_seconds |
timer | realm, client_id, initiator, outcome |
StreamVerificationService, sync push-to-receiver duration. Not recorded for the rate_limited outcome (no dispatch happened). |
keycloak_ssf_outbox_depth |
gauge | realm, status (PENDING / HELD / DEAD_LETTER) |
Refreshed at the end of each drainer tick from a single grouped COUNT(*). Scrapes read the cached snapshot. |
realm— uses the realm name (e.g.realm="ssf-poc"), not the realm UUID, so dashboards and alerts are operator-friendly. The drainer translates the outbox row'srealmId(UUID column) into the realm name on every metric emission; if a realm has been deleted but outbox rows still exist for a moment, the realmId is used verbatim as the label so the orphaned-row signal isn't lost.client_id— uses the receiver's OAuthclient_id, not the internal Keycloak UUID. Same readability rationale.event_type— uses the receiver-friendly alias (e.g.CaepCredentialChange) resolved viaSsf.events().getRegistry() .resolveAliasForEventType(...). Falls back to the full URI when the event type isn't registered, so custom or unknown event types remain observable.
Per realm × per receiver client × a small fixed label set. Receiver
clients per realm are typically 1–5 in practice (often zero), so a
deployment with N realms produces roughly N × 5 × meter-cardinality
series — comfortably inside Prometheus's normal operating range. The
outbox_depth gauge is intentionally NOT labeled by client_id; per-
client backlog visibility is left to the admin UI's Pending Events tab,
which already serves on-demand depth per receiver.
The drainer-tick counter is intentionally NOT labeled by node id: cluster-aggregate rate answers "is SSF draining anywhere?" cleanly, and Kubernetes pod-name churn keeps node-labeled series otherwise stale-prone. Operators who need to debug per-node leader-election behavior can correlate the SSF drainer-tick rate against the per-node JGroups / Infinispan meters Keycloak already exposes.
The metrics binder is built once at SSF SPI factory init() time and
shared across every session-scoped dispatcher, the long-lived drainer
task, and the per-request poll endpoint (resolved via
SsfTransmitterProvider#metrics()). Three gates apply, evaluated in
order:
- SSF-level toggle — the
metrics-enabledSPI property on thessf-transmittercategory (defaulttrue). Setting it tofalseinstallsSsfMetricsBinder.NOOP; every meter call becomes a branch-predicted no-op, and the drainer skips the per-tick depth aggregate query. - Keycloak-global metrics endpoint — controlled by Keycloak's own
--metrics-enabledbuild option. When that's off, the global Micrometer registry has no concrete registries attached and any meter increments harmlessly land in an empty composite. SSF doesn't pre-check this on its own (early SPI init runs before Quarkus wires the Prometheus registry, so a pre-flight check would always fail); the no-op cost is bounded. - Micrometer classpath availability — a
LinkageErrorcatch around the binder construction installsNOOPif a custom Keycloak distribution stripped Micrometer, so SSF stays functional.
The binder uses io.micrometer.core.instrument.Metrics.globalRegistry
via Counter.builder(...).register(registry) — same pattern Keycloak's
existing meter contributors use (PasswordCredentialProviderFactory,
MicrometerUserEventMetricsEventListenerProviderFactory,
ExpirationTaskFactory). Late-attached concrete registries (Prometheus,
OTLP) pick up our meters as soon as they bind to the global registry.
The standard Prometheus relabeling tools work; nothing SSF-specific is
required. To keep only SSF series in long-term storage, drop everything
else with a metric_relabel_configs keep action on __name__ matching
keycloak_ssf_.*. Quarkus' /metrics endpoint also supports the
?name[]=... query parameter for ad-hoc one-off filtering.
All paths below are rooted at /realms/{realm}/.
| Method | Path | Purpose |
|---|---|---|
GET |
.well-known/ssf-configuration |
Transmitter metadata document (SSF 1.0). |
GET |
.well-known/sse-configuration |
Legacy SSE subset of the SSF metadata for pre-SSF receivers. Disableable via SPI config. |
POST |
ssf/transmitter/streams |
Create a stream. |
GET |
ssf/transmitter/streams |
Get the stream (optionally with ?stream_id=). |
PATCH |
ssf/transmitter/streams |
Merge-update receiver-writable fields. |
PUT |
ssf/transmitter/streams |
Full-replace receiver-writable fields. |
DELETE |
ssf/transmitter/streams |
Delete the stream + cascade-purge outbox rows. |
GET |
ssf/transmitter/streams/status |
Read stream status. |
POST |
ssf/transmitter/streams/status |
Change stream status (enabled / paused / disabled). |
POST |
ssf/transmitter/verify |
Trigger a verification SET. |
POST |
ssf/transmitter/subjects/add |
Subscribe a subject to the stream. |
POST |
ssf/transmitter/subjects/remove |
Unsubscribe a subject. |
POST |
ssf/transmitter/receivers/{clientId}/streams/{streamId}/poll |
RFC 8936 POLL. |
The transmitter metadata document (.well-known/ssf-configuration) is
published under two URL shapes so both Keycloak-native and
RFC 8615-strict receivers can
discover it:
https://KC_HOSTNAME/realms/myrealm/.well-known/ssf-configuration— the realm-prefixed form, consistent with Keycloak's OIDC / OAuth well-known endpoints. Most tooling and documentation examples use this shape.https://KC_HOSTNAME/.well-known/ssf-configuration/realms/myrealm— the host-rooted form required by RFC 8615 (well-known URIs MUST appear directly under the origin host root, with any path disambiguator trailing after the suffix). Receivers that follow the RFC strictly will probe this shape first. Caveat: this form is only reachable when Keycloak is served at the web-root — i.e. theKC_HTTP_RELATIVE_PATHoption is empty (the default). When Keycloak is deployed under a relative path such as/auth(KC_HTTP_RELATIVE_PATH=/auth), only the realm-prefixed form (https://KC_HOSTNAME/auth/realms/myrealm/.well-known/ssf-configuration) is served; the RFC 8615 shape would need to live athttps://KC_HOSTNAME/.well-known/...which sits outside the Keycloak context root and is not something Keycloak can route by itself. Exposing the RFC 8615 shape in such a deployment requires a URL-rewriting reverse proxy in front of Keycloak.
Both URLs resolve to the exact same metadata document for the same realm.
Alongside the SSF 1.0 endpoint, the transmitter also publishes the
predecessor SSE (Shared Signals and Events) metadata at
.well-known/sse-configuration (again under both realm-prefixed and
host-rooted shapes). This is a subset of the SSF document tailored
to legacy receivers that predate SSF 1.0: the SSF-only fields
(default_subjects, spec_version, status_endpoint,
authorization_schemes) are dropped, and the new urn:… delivery
methods are filtered out of delivery_methods_supported so only the
http-scheme URIs known to SSE-era receivers remain. Everything else
(issuer, jwks_uri, remaining delivery methods, configuration /
verification endpoints) is the same document the SSF endpoint serves.
The legacy endpoint is registered behind Keycloak's standard
WellKnownProvider SPI as sse-configuration and can be disabled per
deployment with the SPI config
spi-wellknown--sse-configuration--enabled=false. Defaults to enabled
so pre-SSF 1.0 receivers that still probe the legacy discovery path
keep working out of the box; deployments that have no such receivers
can turn it off and serve only ssf-configuration.
Note that the two dimensions — which metadata document a receiver
discovers and which SET wire format it expects — are
independent. Apple Business Manager / Apple School Manager, for
example, discover transmitter metadata via the modern
ssf-configuration endpoint but expect SETs in the older SSE CAEP
message format; their SSE-ness lives in the per-receiver profile
setting (ssf.profile=SSE_CAEP) that drives the dispatcher's
narrowing converter, not in the metadata endpoint they read. The
legacy sse-configuration endpoint is orthogonal — it's there for
other products that predate the SSF 1.0 metadata shape and refuse
to parse the newer one.
All paths below are rooted at /admin/realms/{realm}/ssf/. Admin endpoints
use the OAuth client_id (not the internal UUID) for consistency with
developer vocabulary. Most endpoints require view-realm /
manage-realm (or fine-grained equivalents) on the realm; the synthetic
emit endpoint additionally accepts trusted-emitter service accounts —
see Synthetic event emitter
for the full authorization rules.
| Method | Path | Purpose |
|---|---|---|
GET |
config |
Realm-level SSF configuration (transmitter defaults). |
POST |
clients/{clientId}/stream |
Admin-create a stream for a receiver. |
GET |
clients/{clientId}/stream |
Admin-read stream (includes admin-only fields). |
DELETE |
clients/{clientId}/stream |
Admin-delete stream + cascade outbox purge. |
POST |
clients/{clientId}/stream/verify |
Admin-trigger verification SET. |
POST |
clients/{clientId}/subjects/add |
Admin-subscribe a subject (user-id / user-email / user-username / org-alias). |
POST |
clients/{clientId}/subjects/remove |
Admin-unsubscribe. |
POST |
clients/{clientId}/subjects/ignore |
Admin-mark subject as explicitly NOT notified. |
POST |
clients/{clientId}/events/emit |
Emit a synthetic SSF event. Callers with permission to manage the receiver client (broad manage-clients role or fine-grained per-client manage permission) bypass the receiver-configured allowEmitEvents opt-in and the role check; trusted-emitter service accounts still gate on allowEmitEvents=true plus the configured emitEventsRole (a missing role configuration returns 403 emit_role_not_configured). |
GET |
clients/{clientId}/pending-events/{jti} |
Admin-lookup: decoded SET + resolved user UUID + delivery metadata. |
Configured via the SSF tab in the client editor; persisted as client attributes.
| Attribute | Purpose |
|---|---|
ssf.enabled |
Master switch per client. |
ssf.profile |
SSF_1_0 (default) or SSE_CAEP (legacy Apple). |
ssf.streamAudience |
Per-client audience override for emitted SETs. |
ssf.supportedEvents |
Comma-separated event URIs / aliases this receiver supports. |
ssf.signatureAlgorithm |
JWS alg override. |
ssf.userSubjectFormat |
iss_sub / email / complex.iss_sub+tenant / complex.email+tenant. |
ssf.defaultSubjects |
ALL / NONE. |
ssf.autoNotifyOnLogin |
Auto-subscribe a user when they log in. |
ssf.requireServiceAccount |
Reject tokens that aren't from a service account. |
ssf.requiredRole |
Extra role gate on top of ssf.* scopes. |
ssf.minVerificationInterval |
Per-client verify rate limit override. |
ssf.verificationTrigger |
When to auto-verify (NEVER / ON_CREATE / …). |
ssf.verificationDelayMillis |
Delay before the transmitter-initiated verification SET. |
ssf.allowEmitEvents |
Enable trusted-emitter synthetic event path. |
ssf.emitEventsRole |
Role a trusted emitter must hold. |
ssf.pushEndpointConnectTimeoutMillis / ssf.pushEndpointSocketTimeoutMillis |
Per-client push HTTP timeout overrides. |
ssf.maxEventAgeSeconds |
Purge any non-DELIVERED outbox row older than this, overriding the global retention. |
ssf.inactivityTimeoutSeconds |
Auto-pause the stream when no receiver activity within this window. |
ssf.lastActivityTimeslot |
Coarse-grained (5-min) activity marker — write-coalesced, never user-editable. |
ssf.lastVerifiedAt |
Timestamp of the most recent verification SET dispatch. |
ssf.streamId, ssf.status, ssf.status_reason |
Per-stream state. |
ssf.notify.<receiverClientId> |
Per-user / per-org subscription flag (on users and organizations, not receivers). |
- POLL long-polling (
returnImmediately=false,maxWait) not implemented — every poll returns immediately, even if the receiver asks the transmitter to hold the request open until new events arrive. The request parameters are parsed so receivers don't get a 400, they're just not honored. Rationale: honoring long-polling would mean the transmitter parks the HTTP request on the server side (sleep / condition-wait) until either new events become available ormaxWaitexpires. On Keycloak today that would pin a real platform thread from the HTTP worker pool for the entire wait window, with N idle receivers trivially able to exhaust the pool and starve every other request in the realm. RFC 8936 explicitly allows a transmitter to return immediately even whenreturnImmediately=falseis set, so the safe choice for now is to always return immediately and let receivers configure their own poll cadence. Revisiting this becomes attractive once Keycloak's HTTP layer can run request handlers on virtual threads — a parked virtual thread doesn't tie up an underlying carrier thread, so the request-pool starvation problem goes away and the implementation becomes essentially a boundedThread.sleep+ "new events?" recheck loop. - Multi-stream per receiver disabled (409 on second create). Rationale: many vendor SSF implementations in the wild also cap at one stream per receiver, and the one-stream model cleanly sidesteps a raft of multi-stream edge cases (routing an incoming event to the right stream when selectors overlap, partitioning the outbox by stream vs by client, migrating queued rows on per-stream delivery-method changes, UX for picking "which stream?" in admin screens). It's flexible enough for the vast majority of use-cases and keeps both the data model and the admin UX simple. Workaround for deployments that genuinely need multiple independent streams for the same downstream: model each stream as its own OAuth receiver client. That keeps streams first-class isolated (independent audience, delivery method, signing key, subject set, pending-event queue, status transitions) and avoids any ambiguity about which stream an event belongs to.
- CAEP
device-compliance-changehas no native Keycloak event source. This is not strictly a limitation — the concept simply has no equivalent inside Keycloak — but it means there is no auto-emit path for it today. External systems (MDM, posture engines) can still surface this event to downstream SSF receivers through the synthetic event emitter using a trusted-emitter service account. The same pattern applies to any signal that happens outside Keycloak (e.g. LDAP password changes done directly against a federated backend). - RSA ≥ 2048 enforcement (CAEP §2.6 MUST) not checked at the SSF layer — relies on the realm's signing-key configuration. Deployments on Keycloak's default RSA key providers already meet the floor; hardening would mean the dispatcher refusing to sign with an undersized key and returning a configuration error instead.
- Dedicated SSF signing key — today we share the realm's OIDC
token-signing key. A follow-up will add a dedicated realm key
provider and advertise it under a separate
jwks_urislice so receivers can pin the SSF-specific key without inheriting OIDC rotation cadence. - Cross-node PUSH order guarantee —
SELECT … FOR UPDATE SKIP LOCKEDsplits batches across drainer workers, so strict per-subject time order isn't guaranteed in multi-node (or multi-worker) deployments. Single-node single-worker ordering is deterministic via the(next_attempt_at, created_at, jti)sort. Receivers needing strict per-subject order should rely on the SET'siat/txnclaims to order at the consumer. - Error envelope keys on non-POLL endpoints use OAuth-style
error/error_descriptionrather than theerr/descriptionshape POLL uses. The SSF spec doesn't mandate a specific envelope for stream-management errors, and the OAuth-style envelope is consistent with everything else Keycloak returns. POLL retains the spec-mandatederr/descriptionshape per RFC 8936 §2.4.4. - Outbox depth gauge is scrape-lagged by up to one drainer tick
(default ~30s). This is intentional — gauges read from a snapshot
the drainer refreshes once per tick rather than running a
per-scrape
COUNT(*). Fine for "backlog growing" alerting; if you need exact depth right now, the admin UI's Pending Events tab serves on-demand counts straight off the outbox. - Chunked HELD release for very large backlogs — when a stream
transitions paused → enabled, all HELD rows for that client are
released to PENDING in a single bulk SQL
UPDATE. For receivers with tens of thousands of held events the resulting transaction- cache invalidation can be heavy. Chunking the release into bounded batches is a GA prerequisite; current tests cover small HELD backlogs only.
- Realm-rename label drift in metrics — the
realmPrometheus label uses the realm name (intentional, for operator readability). Renaming a realm produces fresh time series under the new name while the old series go stale until Prometheus' retention drops them. Realm renames are rare in practice; the tradeoff favors readability over series stability. - Host-rooted
.well-knownURL requires web-root deployment — the RFC 8615 shape/.well-known/ssf-configuration/realms/{realm}is only reachable whenKC_HTTP_RELATIVE_PATHis empty (the default). Under a relative path (e.g./auth) only the realm-prefixed…/realms/{realm}/.well-known/ssf-configurationshape is served by Keycloak directly; exposing the RFC 8615 shape needs a URL-rewriting reverse proxy in front of Keycloak.
Experimental-ready now. The feature is gated behind
Profile.Feature.SSF, covered by 100+ integration tests, addresses the
major SSF / CAEP spec surfaces end-to-end, and ships with a Prometheus
metrics binder for hot-path observability (dispatcher / drainer / poll
counters + outbox-depth gauge).
Preview requires: user docs, API-surface freeze for one release, and a formal interop matrix (caep.dev, Apple Business Manager).
Supported / GA requires: POLL long-polling (or Keycloak virtual- thread HTTP handlers landing first), dedicated SSF signing key, RSA key-length enforcement at the dispatcher, chunked HELD release for large backlogs, performance characterization with documented SLAs, and a security review pass.