Skip to content

Instantly share code, notes, and snippets.

@thomasdarimont
Last active May 6, 2026 08:22
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 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.

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 using 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 (SecurityEventTokenDispatcher) applies subscription / status / subject gates and hands the signed SET to the outbox.
  2. 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 the entryKind discriminator (SSF uses ssf-push and ssf-poll). For PUSH, a cluster-aware drainer (OutboxDrainerTask) pulls due rows, hands them to the SSF-specific SsfPushDeliveryHandler, 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 (Receiver / Stream / Subjects / Event Search / Emit Events) 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-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.

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

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 OUTBOX_ENTRY 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. The same generic outbox table serves both delivery methods, distinguished by the entryKind column (ssf-push vs ssf-poll). POLL receivers fetch rows directly; the PUSH drainer processes ssf-push rows 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 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: OUTBOX_ENTRY

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 on RealmRemovedEvent.
  • 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_requested narrowed).

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.

Outbox row state machine

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

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. (ENTRY_KIND, OWNER_ID, CORRELATION_ID) unique constraint as the at-least-once dedup guard. The application-level dedup scan in OutboxStore.enqueueInStatus avoids paying the constraint violation cost for the common no-op path. As a final safety net, the actual INSERT is the named query OutboxEntryEntity.insertIfAbsent, which uses HQL INSERT … 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 throwing ConstraintViolationException and 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 ASC per the OutboxEntryEntity.findDueForDrain named query, with created_at and correlation_id acting as natural tiebreakers within a single receiver.
  • 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, 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.

Safety invariants worth keeping in mind

  • Signing-key rotation: drainer retries reuse the PAYLOAD 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 OutboxCleanupTask to the ssf-outbox-cleanup ExecutorsProvider pool — one task per SSF entryKind (ssf-push and ssf-poll) — which drains the orphaned rows in short batched transactions. 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 drainer's pendingMaxAge backstop 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 inline OutboxStore.deleteByOwner family — 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: the ProviderEventListener registered by DefaultSsfTransmitterProviderFactory hooks ClientUpdatedEvent via validateImportedStreamId 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. 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 PAYLOAD already matches the receiver's negotiated wire shape. The drainer doesn't know or care about profiles.

SSRF defence on receiver-supplied push URLs

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:

  1. Per-receiver allow-list — ssf.validPushUrls. ##-separated (Constants.CFG_DELIMITER) list of allowed URL patterns. Each entry is matched against the receiver-supplied delivery.endpoint_url using exact-match or trailing-* suffix wildcard semantics (e.g. https://recv.example.com/feeds/*). The match logic is a deliberate copy of RedirectUtils.matchesRedirects (see SsfPushUrlValidator.matchesAllowList) — kept SSF-local so the security surface of OIDC redirect-URI matching isn't touched by this gate. Bare * entries are filtered out by SsfPushUrlValidator.filterUsableEntries so 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 reason ALLOWLIST_EMPTY.
  2. Post-match scheme / host class check. After a successful allow-list match, the receiver-supplied URL itself is re-validated: scheme MUST be https and the resolved host MUST NOT be loopback / any-local / link-local / site-local / unique-local IPv6 (RFC 4193 fc00::/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 mirrors SslRequired.isLocal in keycloak-common.
  3. allow-insecure-push-targets SPI escape hatch. The ssf-transmitter SPI flag (default false) on SsfTransmitterConfig relaxes 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 in start-dev: a developer who tests PUSH integration against http://localhost would 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.

Delivery-method family gating

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.

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>, 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/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 Emit 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. 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.

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.
  • Receiver-supplied PUSH URLs are gated by the per-client ssf.validPushUrls allow-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.allowedDeliveryMethods list when set (see Delivery-method family gating).
  • 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 OUTBOX_ENTRY, entryKind=ssf-push) with cluster-safe drainer (UPGRADE_SKIPLOCKED) wrapped in ClusterAwareScheduledTaskRunner so one node drains per tick.
  • Generic OutboxDrainerTask invokes the SSF-specific SsfPushDeliveryHandler, which resolves the receiver / stream and hands the row to PushDeliveryService for the actual HTTP send.
  • Exponential backoff (OutboxBackoff, DEFAULT_HTTP_PUSH_CURVE with DEFAULT_MAX_ATTEMPTS=8), DEAD_LETTER terminal state after exhaustion.
  • Accepts any 2xx response from the receiver as success.
  • SSRF gate on delivery.endpoint_url via SsfPushUrlValidator (per- receiver ssf.validPushUrls allow-list + scheme/host class check).
  • Per-receiver push endpoint connect / socket timeouts configurable.

POLL delivery (RFC 8936)

  • Endpoint URL transmitter-owned: /realms/{realm}/ssf/transmitter/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.
  • Global outbox-delivered-retention (default 24h) for DELIVERED rows — kept post-delivery to preserve jti dedup 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-receiver ssf.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.

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

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 five sub-tabs:

  • Receiver (ssfTabReceiver) — the receiver's configuration / policy knobs, organised into sections via ScrollForm: 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 the ssf.* 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 via OutboxStore.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 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.
  • Event Search (ssfTabEventSearch) — outbox lens 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). Backed by GET /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's allowEmitEvents opt-in (trusted-emitter service accounts still gate on emitEventsRole).

Receiver tab — Delivery section

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-client ssf.allowedDeliveryMethods attribute. 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, or poll) 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 MultiLineInput writes the per-client ssf.validPushUrls attribute (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.

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, 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 SsfEventProviderFactorySsfEventProvider 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.

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/transmitter/... — stream management, status, subjects, verification, and the POLL endpoint (the transmitter segment is Ssf.SSF_TRANSMITTER_PATH).
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, 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.

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

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

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 SsfTransmitterContext#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}/. 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. 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}/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).

Client attributes (per receiver, ssf.*)

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.

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

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