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 or are
pushed 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 using 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 (SecurityEventTokenDispatcher) applies subscription / status / subject gates and hands the signed SET to the outbox. - Durable outbox (
OUTBOX_ENTRY). Every SET is persisted before the user's request returns. A single generic JPA table serves both PUSH and POLL — and is shared with other Keycloak subsystems via theentryKinddiscriminator (SSF usesssf-pushandssf-poll). For PUSH, a cluster-aware drainer (OutboxDrainerTask) pulls due rows, hands them to the SSF-specificSsfPushDeliveryHandler, 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 (Receiver / Stream / Subjects / Event Search / Emit Events) 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-safe delivery.
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 │
│ (OUTBOX_ENTRY, │ │ (OUTBOX_ENTRY, │
│ entryKind= │ │ entryKind= │
│ ssf-push) │ │ ssf-poll) │
└───────┬────────┘ └───────┬────────┘
▼ ▼
┌──────────────────────────┐ ┌─────────────────────────────┐
│ OutboxDrainerTask │ │ SsfStreamPollResource │
│ — cluster-aware tick │ │ — receiver pulls pending │
│ — SsfPushDeliveryHandler │ │ 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 inOUTBOX_ENTRYbefore 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. The same generic outbox table serves
both delivery methods, distinguished by the
entryKindcolumn (ssf-pushvsssf-poll). POLL receivers fetch rows directly; the PUSH drainer processesssf-pushrows on its schedule. State-machine transitions (HELD / DEAD_LETTER) apply uniformly to both. - At-least-once semantics with dedup. A
(entryKind, ownerId, correlationId)unique constraint prevents double-enqueue on retried dispatches. Receivers see eachjtiat 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.
What the SSF transmitter writes goes through the generic outbox
infrastructure (org.keycloak.events.outbox.OutboxStore,
org.keycloak.models.jpa.entities.OutboxEntryEntity), which lives in
model/jpa rather than in any SSF-specific module. Multiple subsystems
(SSF, future webhook delivery, …) share this single table; the ENTRY_KIND
discriminator scopes every read and write so cross-consumer contention
is eliminated by the (ENTRY_KIND, …) compound indexes rather than by
separate tables.
Columns on OutboxEntryEntity:
| Column | Purpose |
|---|---|
ID |
Primary key (UUID). |
ENTRY_KIND |
Subsystem discriminator. SSF uses ssf-push / ssf-poll (constants on SsfOutboxKinds). |
REALM_ID |
Realm the receiver lives in; enables realm-delete cascade. |
OWNER_ID |
Receiver's internal client UUID (for SSF) — primary scoping key, drives per-owner stats / delete / cleanup. |
CONTAINER_ID |
Optional sub-grouping within (ENTRY_KIND, OWNER_ID). For SSF this is the receiver's stream id so stream-scoped operations stay SQL-filterable. |
CORRELATION_ID |
The SET's unique JWT id (jti) — part of the (ENTRY_KIND, OWNER_ID, CORRELATION_ID) dedup constraint. |
ENTRY_TYPE |
Concrete type within the kind. For SSF this is the event type alias (e.g. CaepCredentialChange). |
PAYLOAD |
The already-signed JWS payload (CLOB). Kept verbatim so the drainer doesn't re-sign on every retry (the realm key may rotate between attempts). |
METADATA |
Optional kind-specific JSON envelope (CLOB); SSF currently writes null. |
STATUS |
PENDING / HELD / DELIVERED / DEAD_LETTER. |
ATTEMPTS, NEXT_ATTEMPT_AT, LAST_ERROR |
Drainer retry state for PUSH. LAST_ERROR is VARCHAR(2048) and is truncated by OutboxStore.MAX_LAST_ERROR_LENGTH. |
CREATED_AT, DELIVERED_AT |
Audit; CREATED_AT also drives per-receiver TTL housekeeping and partition-friendly layout. |
The Liquibase changelog META-INF/jpa-changelog-26.7.0.xml (changeset
26.7.0-outbox) ships the table plus four indexes:
IDX_OUTBOX_DRAIN(ENTRY_KIND, STATUS, NEXT_ATTEMPT_AT)— drainer hot path.IDX_OUTBOX_REALM(ENTRY_KIND, REALM_ID, STATUS)— realm-scoped reads / cascade deletes onRealmRemovedEvent.IDX_OUTBOX_OWNER(ENTRY_KIND, OWNER_ID, STATUS)— admin per-owner reads / per-receiver cleanup.IDX_OUTBOX_CONTAINER(ENTRY_KIND, OWNER_ID, CONTAINER_ID)— stream- scoped lifecycle (SSF stream disabled,events_requestednarrowed).
The unique constraint UC_OUTBOX_KIND_OWNER_CORRELATION(ENTRY_KIND, OWNER_ID, CORRELATION_ID) is the at-least-once dedup guard. Two
consumers using the same correlation id (e.g. a jti reused across a
webhook and an SSF stream) don't collide because the ENTRY_KIND prefix
scopes them apart.
┌────────────── admin "retry"──────────────┐
│ (reset attempts, │
│ next_attempt_at) │
▼ │
┌──────────┐ ┌─────────────┐
│ PENDING │─────────── retries ────────▶│ DEAD_LETTER │
└────┬─────┘ exhausted └─────────────┘
│
│ push succeeds / POLL ack
▼
┌───────────┐
│ DELIVERED │ (kept for the configured
└───────────┘ outbox-delivered-retention window —
default 24h — 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 entryKind.)
┌──────────┐
│ 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.(ENTRY_KIND, OWNER_ID, CORRELATION_ID)unique constraint as the at-least-once dedup guard. The application-level dedup scan inOutboxStore.enqueueInStatusavoids paying the constraint violation cost for the common no-op path. As a final safety net, the actualINSERTis the named queryOutboxEntryEntity.insertIfAbsent, which uses HQLINSERT … ON CONFLICT DO NOTHING(Hibernate 6.5+) so a sibling node racing on the same triple causes the local insert to no-op (returns 0 rows) instead of throwingConstraintViolationExceptionand marking the JTA transaction rollback-only. The caller distinguishes the two outcomes via the row count and re-fetches the racing row's id on conflict.
Where each mechanism applies:
| Operation | Concurrency control |
|---|---|
| Drainer tick (PUSH dispatch + housekeeping) | ClusterAwareScheduledTaskRunner singleton across the cluster. |
PUSH row claim inside a drainer tick (OutboxStore.lockDueForDrain) |
UPGRADE_SKIPLOCKED + setMaxResults(batchSize) — one node, multiple workers, disjoint batches. |
POLL row claim (OutboxStore.lockPendingForOwner) |
UPGRADE_SKIPLOCKED — multiple receiver pods polling the same stream get disjoint batches without duplicates. |
Enqueue (enqueuePending / enqueueHeld) |
Local dedup scan + INSERT … ON CONFLICT DO NOTHING (named query OutboxEntryEntity.insertIfAbsent) backed by the unique constraint. No row lock taken. |
Bulk status transitions (holdPendingForOwner, releaseHeldForOwner, deleteQueuedForOwner) |
JPA bulk UPDATE / DELETE scoped to one receiver. Same-receiver concurrent transitions serialize on row locks; disjoint receivers don't contend. |
| Per-owner TTL purge | 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 ASCper theOutboxEntryEntity.findDueForDrainnamed query, withcreated_atandcorrelation_idacting as natural tiebreakers within a single receiver. - 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, DeliveryMethod and DeliveryMethodFamily. 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, SET mapper + encoder, push / poll delivery services, SSE CAEP narrowing converter, receiver-facing JAX-RS resources, admin DTOs, SsfPushUrlValidator, SsfTransmitterContext / SsfTransmitterServiceBuilder, SsfPushDeliveryHandler (the SSF-specific glue that OutboxDrainerTask invokes per row). |
ssf/services |
keycloak-ssf-services |
Admin REST resource (SsfAdminResource), realm-level JAX-RS wiring (SsfRealmResourceProvider / SsfAdminRealmResourceProvider), bootstrap helper. |
ssf/tests/base |
keycloak-ssf-tests-base |
Integration tests against the Keycloak test framework. |
The generic outbox infrastructure (OutboxEntryEntity, OutboxStore,
OutboxDrainerTask, OutboxBackoff, OutboxConfig,
OutboxCleanupTask, OutboxDeliveryHandler) lives in model/jpa
under org.keycloak.models.jpa.entities and
org.keycloak.events.outbox. The OutboxEntryEntity is registered in
META-INF/default-persistence.xml like any other core entity — SSF
does not contribute a JpaEntityProvider; that table is shared
infrastructure now.
Each SSF module is gated on Profile.Feature.SSF — the provider
factories 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
PAYLOADstored 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 anOutboxCleanupTaskto thessf-outbox-cleanupExecutorsProviderpool — one task per SSF entryKind (ssf-pushandssf-poll) — which drains the orphaned rows in short batched transactions. InlineDELETEwould 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 drainer'spendingMaxAgebackstop and dead-letter retention purge sweep orphan rows on subsequent ticks. Outbox rows are plain columns (no FK into core tables) 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 call the inlineOutboxStore.deleteByOwnerfamily — 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: theProviderEventListenerregistered byDefaultSsfTransmitterProviderFactoryhooksClientUpdatedEventviavalidateImportedStreamIdand 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.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
PAYLOADalready matches the receiver's negotiated wire shape. The drainer doesn't know or care about profiles.
PUSH delivery has the transmitter make an outbound HTTP call to a
receiver-supplied URL. Without guardrails an attacker who can register
a receiver client could point that URL at internal services
(http://169.254.169.254/, http://127.0.0.1/admin/...,
http://10.0.0.5/internal-api) and turn the transmitter into an SSRF
oracle — every signed SET arriving at an unintended internal endpoint
is a potential leak.
The defence has three layers:
- Per-receiver allow-list —
ssf.validPushUrls.##-separated (Constants.CFG_DELIMITER) list of allowed URL patterns. Each entry is matched against the receiver-supplieddelivery.endpoint_urlusing exact-match or trailing-*suffix wildcard semantics (e.g.https://recv.example.com/feeds/*). The match logic is a deliberate copy ofRedirectUtils.matchesRedirects(seeSsfPushUrlValidator.matchesAllowList) — kept SSF-local so the security surface of OIDC redirect-URI matching isn't touched by this gate. Bare*entries are filtered out bySsfPushUrlValidator.filterUsableEntriesso the SSRF defence can't be disabled with a single keystroke; structurally invalid wildcard entries (host portion containing*/?, missing scheme/host, non-http(s) scheme) are silently dropped from the effective set as well. An empty / absent attribute is treated as a hard reject when PUSH is requested, with reasonALLOWLIST_EMPTY. - Post-match scheme / host class check. After a successful
allow-list match, the receiver-supplied URL itself is re-validated:
scheme MUST be
httpsand the resolved host MUST NOT be loopback / any-local / link-local / site-local / unique-local IPv6 (RFC 4193fc00::/7) / multicast. Unresolvable hostnames are accepted — a push to an unresolvable host fails harmlessly at delivery time and is not an SSRF. The predicate combination mirrorsSslRequired.isLocalinkeycloak-common. allow-insecure-push-targetsSPI escape hatch. Thessf-transmitterSPI flag (defaultfalse) onSsfTransmitterConfigrelaxes step 2 for closed-network deployments and integration tests pushing to a local mock server. Step 1 continues to apply regardless. We deliberately do NOT auto-relax this instart-dev: a developer who tests PUSH integration againsthttp://localhostwould otherwise never see the production-mode rejection and discover the misconfiguration only at deploy time.
The validator (SsfPushUrlValidator, package
org.keycloak.ssf.transmitter.support) is built once at SPI factory
init time via SsfTransmitterServiceBuilder.createPushUrlValidator(config),
hung off SsfTransmitterContext.pushUrlValidator(), and consulted
from StreamService.validatePushEndpointAgainstReceiverAllowList(...)
which is in turn invoked from StreamService.validateDeliveryMethod
on every receiver-driven create / update path.
Rejection produces a SsfPushUrlValidationException (extends
SsfException → HTTP 400). The exception carries a stable
SsfPushUrlValidator.Reason enum (URL_MISSING, URL_MALFORMED,
ALLOWLIST_EMPTY, NOT_IN_ALLOWLIST, SCHEME_INSECURE,
HOST_PRIVATE) so tests / integrations can map a rejection to a
specific cause without string-matching the user-facing message. The
receiver-facing 400 stays generic — it does not echo the rejected
URL or suggest an allow-list value, to avoid leaking diagnostic detail
to a potentially malicious caller. The operator sees the full picture
in the server log: StreamService.logPushUrlRejection emits a WARN
with the rejected URL and a suggested ssf.validPushUrls entry
(derived as scheme://host[:port]/* by
StreamService.suggestValidPushUrlEntry), so an admin can fix the
client attribute in one step and the receiver can retry.
Receivers select a delivery method at stream-create time. Operators
who want to forbid the transmitter from making outbound HTTP calls to
a particular receiver (POLL-only deployments) — or to require it
(PUSH-only) — gate this per receiver via the ssf.allowedDeliveryMethods
client attribute. The attribute stores ##-separated tokens
(Constants.CFG_DELIMITER) drawn from the canonical
DeliveryMethodFamily values: push and poll. Empty / absent ⇒
both families allowed (transmitter-default behaviour preserved).
The four wire URIs (urn:ietf:rfc:8935,
urn:ietf:rfc:8936,
https://schemas.openid.net/secevent/risc/delivery-method/push,
https://schemas.openid.net/secevent/risc/delivery-method/poll)
collapse into the two operator-facing families via the
DeliveryMethod#family() method:
PUSH, RISC_PUSH -> DeliveryMethodFamily.PUSH
POLL, RISC_POLL -> DeliveryMethodFamily.POLL
This keeps the admin-UI gate ("which delivery methods may this receiver
use?") down to two checkboxes regardless of whether the legacy SSE
CAEP profile is enabled on the transmitter. The
StreamService.validateAllowedDeliveryMethod check runs before the
push-URL allow-list so a POLL-only receiver that tries to register a
PUSH stream gets a clear delivery method 'push' is not allowed for this receiver error, not a misleading "no push URLs configured"
follow-on.
A misconfigured attribute (set but parses to no recognised family) is treated as a hard error rather than silently falling through to the default allow-both: an admin who set the attribute meant to restrict, not to allow more.
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>,
the REMOVED_AT_PREFIX constant on SsfNotifyAttributes) 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 Emit 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. The same builder also
exposes createPushUrlValidator(SsfTransmitterConfig) — override it
if a deployment needs a custom SSRF gate on top of (or in place of)
SsfPushUrlValidator.
- 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.- Receiver-supplied PUSH URLs are gated by the per-client
ssf.validPushUrlsallow-list and the post-match scheme/host class check (see SSRF defence). - The receiver-requested delivery method's family must be present in
the per-client
ssf.allowedDeliveryMethodslist when set (see Delivery-method family gating). - 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
OUTBOX_ENTRY,entryKind=ssf-push) with cluster-safe drainer (UPGRADE_SKIPLOCKED) wrapped inClusterAwareScheduledTaskRunnerso one node drains per tick. - Generic
OutboxDrainerTaskinvokes the SSF-specificSsfPushDeliveryHandler, which resolves the receiver / stream and hands the row toPushDeliveryServicefor the actual HTTP send. - Exponential backoff (
OutboxBackoff,DEFAULT_HTTP_PUSH_CURVEwithDEFAULT_MAX_ATTEMPTS=8), DEAD_LETTER terminal state after exhaustion. - Accepts any 2xx response from the receiver as success.
- SSRF gate on
delivery.endpoint_urlviaSsfPushUrlValidator(per- receiverssf.validPushUrlsallow-list + scheme/host class check). - Per-receiver push endpoint connect / socket timeouts configurable.
- Endpoint URL transmitter-owned:
/realms/{realm}/ssf/transmitter/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. - Global
outbox-delivered-retention(default 24h) for DELIVERED rows — kept post-delivery to preservejtidedup coverage for at-least-once enqueue paths that retry shortly after a successful delivery. - Global
outbox-pending-max-age(default 2d) backstop — promotes any PENDING row older than this to DEAD_LETTER so the dead-letter retention purge eventually deletes it. Bounds the worst case where rows would otherwise sit forever (no per-receiverssf.maxEventAgeSeconds, no realm/client removal, push attempts repeatedly blocked). - 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 and admin status transition.
- 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).
- Per-realm and per-receiver event stats / queue purge endpoints.
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 five sub-tabs:
- Receiver (
ssfTabReceiver) — the receiver's configuration / policy knobs, organised into sections viaScrollForm: General, Authentication, Delivery, Events, Subjects, Verification (and a few feature-scoped sub-sections). Knobs include profile (SSF_1_0/SSE_CAEP), stream audience override, per-receiver default subjects, user subject format, verification trigger, minimum verification interval, required role / service account, auto-notify-on-login, allow synthetic events + emit-events-role, max event age, inactivity timeout. Each knob is a client attribute under thessf.*namespace (see Client attributes). The Delivery section is described in detail below. - Stream (
ssfTabStream) — 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 viaOutboxStore.migrateEntryKindForOwner, hold-on-paused, discard-on-disabled). - Subjects (
ssfTabSubjects) — subject management for the stream. Add, remove, or ignore (explicit-exclude) a user or an organization as a subscription target. Reads / writes thessf.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. - Event Search (
ssfTabEventSearch) — outbox lens for this receiver. Lookup byjtireturns the full decoded SET plus the resolved user UUID and delivery metadata (status, attempts, last error, next attempt at). Backed byGET /admin/realms/{realm}/ssf/clients/{clientId}/pending-events/{jti}. - Emit Events (
ssfTabEmitEvents) — synthesize a SET on demand. An emit form lets the admin pick an event type, 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).
The Delivery section sits between Authentication and Events on the Receiver sub-tab and concentrates everything an operator needs to control which delivery methods this receiver may use, and — when PUSH is allowed — which URL prefixes the transmitter is permitted to push to.
- Allowed delivery methods — two checkboxes (
Push delivery,Poll delivery) drive the per-clientssf.allowedDeliveryMethodsattribute. Empty / unset means "both allowed" (transmitter default); the UI initialises both checkboxes as checked in that case. The checkboxes write the attribute as##-separated canonical tokens (push##poll,push, orpoll) preserving the wire-canonical order so a round-trip through storage doesn't churn the value. The UI silently re-enables a method if the user attempts to uncheck both — at least one method must remain selected. - Valid push URLs — conditionally rendered when Push delivery
is selected. A
MultiLineInputwrites the per-clientssf.validPushUrlsattribute (also##-separated). Each entry is matched against the receiver-supplied URL using exact match or trailing-*suffix wildcard (e.g.https://receiver/push/*). A URL outside the allow-list is rejected with HTTP 400 — this is the receiver's SSRF defence. Bare*is not honoured.
i18n keys: ssfSectionDelivery, ssfSectionDeliveryHelp,
ssfAllowedDeliveryMethods, ssfAllowedDeliveryMethodsHelp,
ssfAllowedDeliveryMethods.push, ssfAllowedDeliveryMethods.pushHelp,
ssfAllowedDeliveryMethods.poll, ssfAllowedDeliveryMethods.pollHelp,
ssfValidPushUrls, ssfValidPushUrlsHelp, ssfValidPushUrls.add.
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, push-URL validator, outbox wiring, and the SsfTransmitterConfig knobs. The factory also implements SsfTransmitterServiceBuilder and is the extension seam for swapping a single per-session service (mapper / encoder / push delivery / push-URL validator / …). All transmitter-wide behavior (signing alg, default subject format, timeouts, …) is configured on this SPI's factory. |
ssf-event |
SsfEventProviderFactory → SsfEventProvider |
DefaultSsfEventProviderFactory (contributes the bundled CAEP and legacy OpenID RISC types) |
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/transmitter/... — stream management, status, subjects, verification, and the POLL endpoint (the transmitter segment is Ssf.SSF_TRANSMITTER_PATH). |
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, per-realm and per-receiver event stats / queue purge. |
TimerProvider + ClusterAwareScheduledTaskRunner |
OutboxDrainerTask (implements Keycloak's ScheduledTask) |
Cluster-singleton scheduled task for draining the PUSH outbox and running retention housekeeping. Scheduled at SSF transmitter startup via TimerProvider#schedule, configured for the ssf-push entryKind. |
ExecutorsProvider |
named pool ssf-outbox-cleanup (constant OUTBOX_CLEANUP_EXECUTOR) |
Runs the background OutboxCleanupTask instances submitted on RealmRemovedEvent / ClientRemovedEvent so the orphan-row delete doesn't serialize into the admin's removal transaction. One task is submitted per SSF entryKind so both ssf-push and ssf-poll rows for the removed scope are reclaimed. |
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 generic outbox store (OutboxStore). Transactions come from the request / scheduled-task tx boundary; the store never opens its own. |
The OutboxEntryEntity itself is registered in
META-INF/default-persistence.xml like any other core entity — SSF
does not contribute a JpaEntityProvider. The entity and its
Liquibase changelog (jpa-changelog-26.7.0.xml, changeset
26.7.0-outbox) ship in model/jpa so multiple subsystems can share
the table without one feature owning the schema.
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-drainer-interval |
30s |
How often the SSF push outbox drainer ticks. Accepts ms/s/m/h suffixes. |
outbox-drainer-batch-size |
50 |
Maximum number of outbox rows processed per drainer tick. |
outbox-drainer-max-attempts |
8 |
Maximum push attempts before an outbox row is dead-lettered (OutboxBackoff.DEFAULT_MAX_ATTEMPTS). |
outbox-dead-letter-retention |
30d |
How long DEAD_LETTER rows are retained before global purge. 0 keeps them indefinitely. |
outbox-delivered-retention |
24h |
How long DELIVERED rows are retained for jti dedup post-delivery. Must be well beyond the maximum backoff window. 0 keeps them indefinitely. |
outbox-pending-max-age |
2d |
Backstop max age for PENDING rows: any older row is bulk-promoted to DEAD_LETTER so retention can purge it. Should be shorter than outbox-dead-letter-retention and larger than the natural retry-curve sum. 0 disables the backstop. |
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. |
allow-insecure-push-targets |
false |
Relaxes the SSRF post-match scheme/host class check so http:// and loopback / private / unique-local / link-local / multicast hosts are permitted. The per-client ssf.validPushUrls allow-list still applies. Intended for closed-network deployments and integration tests; never auto-enabled in start-dev. |
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 Event Search 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
SsfTransmitterContext#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}/. The transmitter
segment is the constant Ssf.SSF_TRANSMITTER_PATH.
| 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}/stream/status |
Admin-change stream status (enabled / paused / disabled). |
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. |
GET |
clients/{clientId}/events/stats |
Per-receiver outbox stats (counts by status, oldest createdAt). |
DELETE |
clients/{clientId}/events |
Per-receiver bulk delete of outbox rows. |
DELETE |
clients/{clientId}/events/queued |
Per-receiver purge of queued (PENDING + HELD) outbox rows. |
GET |
events/stats |
Realm-level outbox stats. |
DELETE |
events |
Realm-level bulk delete of outbox rows. |
DELETE |
events/queued |
Realm-level purge of queued outbox rows (used when the realm-level transmitter toggle is turned off). |
Configured via the SSF tab in the client editor; persisted as client
attributes. The constants live on ClientStreamStore (e.g.
SSF_ENABLED_KEY, SSF_VALID_PUSH_URLS_KEY).
| 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.emitOnlyEvents |
Subset of ssf.supportedEvents that the native event listener will NOT auto-emit (synthetic-emit-only). Same comma-separated alias shape as ssf.supportedEvents. |
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.autoVerifyStream / ssf.verificationDelayMillis |
When/how soon to auto-fire transmitter-initiated verification after stream create. |
ssf.subjectRemovalGraceSeconds |
Per-receiver override of the SSF §9.3 grace window (positive value = grace, 0 = immediate, unset = fall back to the SPI default). |
ssf.allowEmitEvents |
Enable trusted-emitter synthetic event path. |
ssf.emitEventsRole |
Role a trusted emitter must hold. |
ssf.allowedDeliveryMethods |
##-separated list (Constants.CFG_DELIMITER) of allowed delivery families (push, poll). Empty/absent ⇒ both allowed. |
ssf.validPushUrls |
##-separated allow-list of URL patterns the receiver may register as its push endpoint (SSRF gate). Required when the receiver requests PUSH. Bare * not honoured. |
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.stream.iss / .aud / .eventsSupported / .eventsRequested / .eventsDelivered / .delivery.method / .delivery.endpointUrl / .delivery.authorizationHeader / .delivery.additionalParameters / .description / .minVerificationInterval / .inactivityTimeout / .format / .defaultSubjects / .createdAt / .updatedAt |
Per-stream split storage (replaces the legacy ssf.streamConfig blob). |
ssf.notify.<receiverClientId> |
Per-user / per-org subscription flag (on users and organizations, not receivers). |
ssf.notifyRemovedAt.<receiverClientId> |
SSF §9.3 tombstone: epoch-second of the last receiver-driven subjects/remove for this user/receiver pair, used by the grace-window check. Cleared on re-add or admin-ignore. |
- 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 thenext_attempt_atsort. 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 Event Search 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(OutboxStore.releaseHeldForOwner). 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, ships SSRF defences on
receiver-supplied push URLs, 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.