Skip to content

Instantly share code, notes, and snippets.

@thomasdarimont
Last active April 20, 2026 22:50
Show Gist options
  • Select an option

  • Save thomasdarimont/75b14d423ee47392d10f86643244b2a2 to your computer and use it in GitHub Desktop.

Select an option

Save thomasdarimont/75b14d423ee47392d10f86643244b2a2 to your computer and use it in GitHub Desktop.
Design Document for Shared Signals Framework Transmitter Capability for Keycloak.

SSF Transmitter Support in Keycloak

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.

High-level overview

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:

  1. Event path. SsfTransmitterEventListener picks up Keycloak events, SecurityEventTokenMapper builds the typed SET, the dispatcher applies subscription / status / subject gates and hands the signed SET to the outbox.
  2. 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 to DELIVERED or DEAD_LETTER. For POLL, receivers pull rows from the Keycloak-owned poll endpoint and ack them. Row claims on both paths use SELECT … FOR UPDATE SKIP LOCKED (via Hibernate's LockMode.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.
  3. 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 the ssf-transmitter SPI 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.

Concepts and design

High-level flow

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         │
                └──────────────────────────┘

Why an outbox

SSF delivery has real-world constraints the request path can't satisfy:

  • Durability across restarts and failover. A LOGOUT event can't be lost because the transmitter was restarted mid-dispatch. Signed SETs persist in SSF_PENDING_EVENT before 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_METHOD column. 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.

Outbox table: SSF_PENDING_EVENT

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).

Outbox row state machine

                 ┌────────────── 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)

Cluster-safe delivery

Keycloak runs HA, so the outbox has to tolerate multiple nodes pulling from the same table simultaneously without duplicating work.

Three mechanisms compose:

  1. ClusterAwareScheduledTaskRunner wraps 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.
  2. UPGRADE_SKIPLOCKED (PostgreSQL / MySQL FOR UPDATE SKIP LOCKED, Hibernate's LockMode.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 plain FOR UPDATE, which is still correct thanks to the cluster-aware wrapper — serialized cluster- wide, no contention to observe.
  3. (CLIENT_ID, JTI) unique constraint as the at-least-once dedup guard. The application-level dedup scan in enqueuePending(...) 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). When releaseHeldForClient flips every HELD row's next_attempt_at to the same now at resume time, the created_at tiebreaker 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 LOCKED splits 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's iat / txn claims to order at the consumer.

Component layout

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.

Safety invariants worth keeping in mind

  • Signing-key rotation: drainer retries reuse the ENCODED_SET stored at enqueue time — if we re-signed on retry, a key rotation between the original dispatch and a retry would produce a different jti or make the receiver's seen-jti dedup lie. Keeping the byte payload stable across retries keeps receiver-side idempotency correct.
  • Realm / client-removed cascade: RealmRemovedEvent and ClientRemovedEvent submit an SsfOutboxCleanupTask to the ssf-outbox-cleanup ExecutorsProvider pool, 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 call deleteByClient inline — 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.streamId rejection: DefaultSsfTransmitterProviderFactory.validateImportedStreamId hooks ClientUpdatedEvent and throws ModelDuplicateException when the client being saved carries an ssf.streamId that another client in the same realm already holds. This catches the JSON export/import footgun — cloning a receiver client without stripping ssf.streamId would otherwise yield two clients sharing a stream id, and the dispatcher's findClientByStreamId picks 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.findClientByStreamId also 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_SET already matches the receiver's negotiated wire shape. The drainer doesn't know or care about profiles.

Subject selection (who gets which event)

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 when default_subjects=NONE.
  • ssf.notify.<receiverClientId>=false — the user is explicitly excluded. Events about this user are suppressed for that receiver even when default_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/remove are 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.

Event registry: available vs built-in

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) eventsregistry.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 via events_requested) are intentionally not allowed to forge them — letting an external caller fire a stream-updated SET 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) eventsregistry.getEmittableEventTypes(): the subset of available events that some registered SsfEventProviderFactory declares 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.

Synthetic event emitter (filling the event-source gap)

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 off auth.clients().canManage(receiver) — that includes the realm-wide manage-clients role 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 + an ssf.emitEventsRole role 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: when allowEmitEvents=true but no role is configured, the endpoint returns 403 emit_role_not_configured rather 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.

Trusted-emitter example: CaepSessionRevoked over curl

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.

  1. 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)
  1. Emit the event. The trusted-emitter path requires the RFC 9493 sub_id form verbatim (the admin-only subjectType/subjectValue shorthand is not honored here). For a session-revoke covering both the user and a specific session, send a complex subject with user and session facets:
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 set ssf.allowEmitEvents=true.
  • 403 emit_role_not_configuredssf.emitEventsRole is 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.

Extending: custom event types

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.

Define the event class

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 …
}

Register via the existing SPI

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.

Emit programmatically

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.

Map a Keycloak event to the custom event

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.

Specs implemented

  • 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, complex with user / session / tenant facets).
  • CAEP Interoperability Profile 1.0 (draft)session-revoked and credential-change event types with accurate change_type.
  • Legacy SSE CAEP profile for Apple Business Manager / Apple School Manager interop (gated behind sse-caep-enabled SPI flag).

What's included

Transmitter metadata

  • /.well-known/ssf-configuration published 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.

Stream management (SSF §8.1)

  • 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) are WRITE_ONLY — never leak on the receiver-facing wire.
  • Multi-stream per receiver is intentionally disabled (409 on second create) — design choice for v1.

Stream status (SSF §8.1.2)

  • GET / POST /streams/status with enabled / 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.
  • enabled from paused/disabled: HELD rows released back to PENDING (with next_attempt_at = now) so PUSH resumes.
  • stream-updated SET dispatched on every status transition (including inactivity-driven pauses — spec carves this out as a MUST).

Subject management (SSF §8.1.3)

  • 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_subjects policy: ALL delivers unless explicitly excluded; NONE delivers only when explicitly included.
  • Admin-only /subjects/ignore path for marking a subject as NOT notified (distinct from "remove from subscription list").

Verification (SSF §8.1.4)

  • 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 initiator label:
    • receiverPOST /realms/{realm}/ssf/transmitter/verify, rate-limited per receiver via min_verification_interval. Returns 429 Too Many Requests when called too frequently; the rate-limit rejection is also recorded as a verification request with outcome="rate_limited" so over-polling is observable without grepping logs.
    • admin — admin Verify button / REST POST /admin/realms/{realm}/ssf/clients/{clientId}/stream/verify (gated on auth.clients().requireManage(receiverClient) — broad manage-clients role 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_id per spec.
  • The ssf.lastVerifiedAt client attribute is stamped only for explicitly-requested verifications (receiver and admin initiators) — even on delivery failure, because the rate-limit semantics are "when did the admin/receiver last ask for this". The transmitter-initiated post-create auto-fire deliberately does not stamp, so a freshly-created stream's first receiver- initiated POST /streams/verify is 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 the keycloak_ssf_verification_requests_total{initiator="transmitter"} counter.

PUSH delivery (RFC 8935)

  • Durable outbox (JPA-backed SSF_PENDING_EVENT) with cluster-safe drainer (UPGRADE_SKIPLOCKED) wrapped in ClusterAwareScheduledTaskRunner so 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.

POLL delivery (RFC 8936)

  • Endpoint URL transmitter-owned: /receivers/{clientId}/streams/{streamId}/poll.
  • Request shape: maxEvents, ack, setErrs; response carries sets and moreAvailable.
  • setErrs (NACK) transitions matched rows to DEAD_LETTER.
  • Batch caps on ack and setErrs (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.

Event mapping

  • LOGOUTCaepSessionRevoked with complex subject (user + session), optional reason_admin.
  • UPDATE_CREDENTIALCaepCredentialChange with change_type=UPDATE.
  • REMOVE_CREDENTIALCaepCredentialChange with change_type=DELETE.
  • RESET_PASSWORDCaepCredentialChange with change_type=UPDATE, credential_type=password.
  • Deprecated clones (UPDATE_PASSWORD, UPDATE_TOTP, REMOVE_TOTP) deliberately skipped to avoid double emission.

Subject formats

  • 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 a ComplexSubjectId and add a tenant sibling carrying the user's first organization (managed-preferred policy — MANAGED membership wins, else first UNMANAGED).

Housekeeping

  • 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.

Admin REST API

  • 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).

Admin UI (Keycloak admin console)

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).

Realm scope

  • Transmitter enable flag — a per-realm master switch stored as the realm attribute ssf.transmitterEnabled. With the server-wide Profile.Feature.SSF flag 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_subjects policy (ALL / NONE), minimum verification interval, push endpoint HTTP timeouts. Surfaced via the admin REST endpoint GET /admin/realms/{realm}/ssf/config.

Client / SSF Receiver scope

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 the ssf.* 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 jti returns 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's allowEmitEvents opt-in (trusted- emitter service accounts still gate on emitEventsRole).

SPIs used and introduced

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.

Newly introduced SPIs

SPI (category) Contract Default implementation Purpose
ssf-transmitter SsfTransmitterProviderFactorySsfTransmitterProvider 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 SsfEventProviderFactorySsfEventProvider 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.

Existing Keycloak SPIs consumed

SPI Implementation class Role in SSF
EventListenerProviderFactory SsfTransmitterEventListenerFactorySsfTransmitterEventListener 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 SsfRealmResourceProviderFactorySsfRealmResourceProvider Hosts the receiver-facing JAX-RS tree at /realms/{realm}/ssf/... — stream management, status, subjects, verification, and the POLL endpoint.
AdminRealmResourceProviderFactory SsfAdminRealmResourceProviderFactorySsfAdminRealmResourceProvider 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 SsfJpaEntityProviderFactorySsfJpaEntityProvider 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.

Service files shipped

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.

SPI configuration (ssf-transmitter category)

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.

Metrics

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.

Meters

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.

Label conventions

  • 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's realmId (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 OAuth client_id, not the internal Keycloak UUID. Same readability rationale.
  • event_type — uses the receiver-friendly alias (e.g. CaepCredentialChange) resolved via Ssf.events().getRegistry() .resolveAliasForEventType(...). Falls back to the full URI when the event type isn't registered, so custom or unknown event types remain observable.

Cardinality budget

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.

Gating + lifecycle

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:

  1. SSF-level toggle — the metrics-enabled SPI property on the ssf-transmitter category (default true). Setting it to false installs SsfMetricsBinder.NOOP; every meter call becomes a branch-predicted no-op, and the drainer skips the per-tick depth aggregate query.
  2. Keycloak-global metrics endpoint — controlled by Keycloak's own --metrics-enabled build 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.
  3. Micrometer classpath availability — a LinkageError catch around the binder construction installs NOOP if 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.

Filtering at scrape

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.

Endpoints added

Receiver-facing (realm-level, OAuth bearer required)

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. the KC_HTTP_RELATIVE_PATH option 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 at https://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.

Admin-facing (admin permission required)

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.

Client attributes (per receiver, ssf.*)

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).

Known limitations / deferred items

  • 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 or maxWait expires. 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 when returnImmediately=false is 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 bounded Thread.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-change has 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_uri slice so receivers can pin the SSF-specific key without inheriting OIDC rotation cadence.
  • Cross-node PUSH order guaranteeSELECT … FOR UPDATE SKIP LOCKED splits 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's iat / txn claims to order at the consumer.
  • Error envelope keys on non-POLL endpoints use OAuth-style error / error_description rather than the err / description shape 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-mandated err / description shape 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 realm Prometheus 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-known URL requires web-root deployment — the RFC 8615 shape /.well-known/ssf-configuration/realms/{realm} is only reachable when KC_HTTP_RELATIVE_PATH is empty (the default). Under a relative path (e.g. /auth) only the realm-prefixed …/realms/{realm}/.well-known/ssf-configuration shape is served by Keycloak directly; exposing the RFC 8615 shape needs a URL-rewriting reverse proxy in front of Keycloak.

Readiness

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment