Skip to main content

Shared Signals (SSF / CAEP)

Three CAEP pages

This is the CAEP transmitter — the outbound SSF protocol and stream config. For the risk/lifecycle policies that generate these events see Agent Governance; for the portal monitoring view see the CAEP Dashboard.

LumoAuth is an OpenID Shared Signals Framework (SSF) 1.0 transmitter for agent security events (SSF is the standard for passing security events such as "session revoked" between systems; CAEP is its profile of event types). When something security-relevant happens to an AI agent — it gets revoked, a JIT task session is suspended by continuous access evaluation, a just-in-time permission is withdrawn, or governance deactivates a dormant agent — LumoAuth pushes a signed security event token (SET) carrying a CAEP 1.0 event to every receiver you configure.

Delivery is push only: LumoAuth POSTs the SET to your endpoint with Content-Type: application/secevent+jwt. Deliveries are asynchronous, retried with exponential backoff, and every attempt is audited (ssf.set_delivered / ssf.set_delivery_failed).

Standard

OpenID Shared Signals Framework (SSF) 1.0 and the CAEP 1.0 profile solve one problem: a security decision made in one system (a session revoked, a device out of compliance) has to reach every other system now, not when a token happens to expire. Events travel as security event tokens. What you must do as a receiver: verify the SET signature against the transmitter jwks_uri, check iss and aud, and deduplicate on jti.

Do this first​

  1. Open /orgs/{orgId}/portal/agent-guard/security-signals (AI Agents → Agent activity → Security signals). You need agents.view to see the page and settings.manage to create anything.
  2. On the Streams (outbound) tab, click Create stream in the page header. A dialog opens.
  3. Enter your receiver's HTTPS endpoint (Delivery endpoint), an Audience, an optional Bearer token, and tick the Events you want. Click Create stream.
  4. Click Send verification on the new stream and confirm your endpoint received the verification SET.
  5. To receive signals from Okta, Entra or another transmitter instead, switch to the Receivers (inbound) tab and click Add receiver (see Receiving signals).

The Security signals page on the Streams (outbound) tab, with the Create stream button and the transmitter issuer

Transmitter metadata​

Receivers discover the transmitter via the org-scoped well-known document (no authentication required):

GET /orgs/{orgId}/api/v1/.well-known/ssf-configuration
{
"spec_version": "1_0",
"issuer": "https://app.lumoauth.dev/orgs/acme/api/v1",
"jwks_uri": "https://app.lumoauth.dev/orgs/acme/api/v1/.well-known/jwks.json",
"delivery_methods_supported": ["urn:ietf:rfc:8935"],
"configuration_endpoint": "https://app.lumoauth.dev/orgs/acme/api/v1/ssf/stream",
"verification_endpoint": "https://app.lumoauth.dev/orgs/acme/api/v1/ssf/verify",
"authorization_schemes": [{ "spec_urn": "urn:ietf:rfc:6749" }],
"events_supported": [
"https://schemas.openid.net/secevent/caep/event-type/session-revoked",
"https://schemas.openid.net/secevent/caep/event-type/token-claims-change",
"https://schemas.openid.net/secevent/caep/event-type/assurance-level-change"
]
}

SETs are signed with the organization signing key; verify signatures against the jwks_uri. The issuer is always the org-scoped issuer — custom domains are not reflected in SSF SETs, so the value is stable across delivery contexts.

Stream setup​

Streams are managed with the same admin credentials as the rest of the Admin API (Bearer token or API key), or in the portal under AI Agents → Agent activity → Security signals (/orgs/{orgId}/portal/agent-guard/security-signals, Streams (outbound) tab). In the portal, Create stream in the page header opens a dialog with the same fields as the API call below, explained in plain words: the Delivery endpoint (the HTTPS URL that receives one signed token per POST — "push delivery"), the Audience (how the receiver names itself; the aud claim), a Bearer token and Events to send. Events are labelled checkboxes ("Agent session revoked", "Agent permission revoked", "Agent downgraded by governance", "User credential changed", "Device compliance changed") with the event-type URI underneath; the three agent events are pre-selected for a new stream. The bearer token is required unless you tick This endpoint accepts unauthenticated deliveries; streams without one are flagged No bearer in the list. Edit… in a stream's actions menu reopens the dialog prefilled — a blank bearer keeps the stored one, the opt-out tick removes it. Creating, editing, pausing, verifying and deleting streams requires the settings.manage permission.

The Create stream dialog: delivery endpoint, audience, bearer token and labelled event types

Standard

Push delivery of security events defines how a transmitter POSTs one SET per request with Content-Type: application/secevent+jwt. What you must do: answer 202 Accepted once the SET is stored (not once it is processed) and return a 400 with an err code only for SETs you will never accept, so retries are not wasted.

Create a stream​

POST /orgs/{orgId}/api/v1/ssf/stream
Content-Type: application/json

{
"delivery": {
"method": "urn:ietf:rfc:8935",
"endpoint_url": "https://receiver.example.com/events",
"authorization_token": "receiver-shared-bearer-token"
},
"audience": "https://receiver.example.com",
"events_requested": [
"https://schemas.openid.net/secevent/caep/event-type/session-revoked",
"https://schemas.openid.net/secevent/caep/event-type/token-claims-change"
]
}
  • endpoint_url must be a public HTTPS URL (private/internal addresses are rejected — deliveries are SSRF-guarded and DNS-pinned).
  • authorization_token is optional; when set it is stored encrypted at rest and sent as Authorization: Bearer … on every delivery. It is write-only — it is never returned by the API.
  • audience becomes the aud claim of every SET on the stream.
  • events_delivered in the response is the intersection of events_requested and events_supported.

The 201 response returns the stream configuration including the generated stream_id (ssf_…).

Read / delete​

GET /orgs/{orgId}/api/v1/ssf/stream # all streams
GET /orgs/{orgId}/api/v1/ssf/stream?stream_id=ssf_… # one stream
DELETE /orgs/{orgId}/api/v1/ssf/stream?stream_id=ssf_…

Multiple streams (one per receiver) are supported. Pause/resume is available in the portal (Pause / Enable in the stream's actions); paused streams receive nothing.

Verify delivery​

Trigger an SSF Verification Event to prove end-to-end delivery:

POST /orgs/{orgId}/api/v1/ssf/verify
Content-Type: application/json

{ "stream_id": "ssf_…", "state": "any-opaque-echo-value" }

The receiver gets a SET with event type https://schemas.openid.net/secevent/ssf/event-type/verification whose payload echoes state.

Event catalog​

Event type URIWhen firedsub_id formatPayload example
…/caep/event-type/session-revokedAn agent is suspended or revoked (admin API / portal status transition)iss_sub — iss = organization issuer, sub = agent id (agent_…){"event_timestamp": 1751328000, "initiating_entity": "admin", "reason_admin": {"en": "Agent \"billing-bot\" transitioned to status \"revoked\""}}
…/caep/event-type/session-revokedContinuous access evaluation suspends a JIT task session (risk threshold / anomalous request frequency)opaque — the CAEP session id (caep_…){"event_timestamp": 1751328000, "initiating_entity": "policy", "reason_admin": {"en": "JIT task session suspended by continuous access evaluation"}, "task_id": "task_…", "agent_id": "agent_…", "evaluation_events": ["risk_threshold_exceeded"]}
…/caep/event-type/token-claims-changeA single JIT permission is revokedopaque — the JIT token id (jti){"event_timestamp": 1751328000, "initiating_entity": "policy", "reason_admin": {"en": "JIT permission revoked: budget exceeded"}, "claims": {"authorization_details": "revoked"}, "task_id": "task_…", "agent_id": "agent_…"}
…/caep/event-type/assurance-level-changeGovernance deactivates a dormant agentiss_sub — iss = organization issuer, sub = agent id{"event_timestamp": 1751328000, "namespace": "https://lumoauth.dev/assurance/agent", "previous_level": "active", "current_level": "dormant-deactivated", "change_direction": "decrease", "initiating_entity": "system", "reason_admin": {"en": "Agent deactivated after 90 days without activity (…)"}}
…/ssf/event-type/verificationOn request (/ssf/verify or the portal button)opaque — the stream id (ssf_…){"state": "any-opaque-echo-value"}

Subject identifiers follow the standard subject-identifier formats for security events. Agent-scoped events use iss_sub because agents are principals of the organization issuer; JIT/task/stream handles use opaque because they are transmitter-scoped identifiers with no issuer semantics of their own. The assurance levels in assurance-level-change are vendor-defined (active / dormant-deactivated) under the https://lumoauth.dev/assurance/agent namespace.

Receiver quickstart​

  1. Expose an HTTPS endpoint that accepts POST with Content-Type: application/secevent+jwt and responds 202 Accepted. Anything other than 2xx is retried with backoff.
  2. Create a stream (above) pointing at it, subscribing to the events you care about.
  3. Send a verification event and check you received it.
  4. Validate each SET: signature against jwks_uri, iss equals the transmitter issuer, aud equals your configured audience, header typ is secevent+jwt, and deduplicate on jti (redeliveries reuse the same jti).

A delivered verification SET decodes to:

// header
{ "alg": "RS256", "kid": "…", "typ": "secevent+jwt" }

// payload
{
"iss": "https://app.lumoauth.dev/orgs/acme/api/v1",
"jti": "set_9f2c1a…",
"iat": 1751328000,
"aud": "https://receiver.example.com",
"sub_id": { "format": "opaque", "id": "ssf_4b1d…" },
"events": {
"https://schemas.openid.net/secevent/ssf/event-type/verification": {
"state": "any-opaque-echo-value"
}
}
}

Minimal Node.js receiver:

import express from 'express';
import { createRemoteJWKSet, jwtVerify } from 'jose';

const JWKS = createRemoteJWKSet(
new URL('https://app.lumoauth.dev/orgs/acme/api/v1/.well-known/jwks.json'),
);

const app = express();
app.post('/events', express.text({ type: 'application/secevent+jwt' }), async (req, res) => {
try {
const { payload, protectedHeader } = await jwtVerify(req.body, JWKS, {
issuer: 'https://app.lumoauth.dev/orgs/acme/api/v1',
audience: 'https://receiver.example.com',
typ: 'secevent+jwt',
});
for (const [eventType, eventPayload] of Object.entries(payload.events)) {
console.log('security event', eventType, payload.sub_id, eventPayload);
}
res.status(202).end();
} catch (e) {
res.status(400).json({ err: 'invalid_request', description: String(e) });
}
});
app.listen(8443);

Receiving signals​

LumoAuth is also an SSF receiver: your identity provider, EDR or MDM can tell it that a user's session must end, a credential was compromised, an account was disabled, or a device fell out of compliance — and LumoAuth acts on it within the same request.

Add a receiver in the portal​

Inbound streams live on the Receivers (inbound) tab of AI Agents → Agent activity → Security signals (/orgs/{orgId}/portal/agent-guard/security-signals?tab=receivers). Creating one requires settings.manage.

The Receivers (inbound) tab of the Security signals page with the Add receiver button

  1. Click Add receiver in the page header (or Add a receiver in the empty state). A dialog opens.
  2. Pick the Transmitter preset. The dialog shows the vendor's docs link and asks only for what the preset cannot derive (for example the Okta org subdomain or the Entra directory id); the issuer and JWKS URL fields appear for free-form presets.
  3. Check the Audience (how the transmitter must address us — the aud claim it puts in every event), then choose How events name the user (by email address, by provider subject, by provider subject strictly, or by phone number — the technical email / sub / iss_sub / phone subject mappings) and the Delivery method: Push — the transmitter sends each event to LumoAuth the moment it happens or Poll — LumoAuth asks the transmitter for new events on a schedule (shows the poll URL and bearer fields).
  4. Tick the Events to act on and click Create receiver.
  5. Copy the push endpoint and the per-stream bearer shown once, and paste them into the transmitter's console.

The Add receiver dialog with the Okta preset selected

Deep links open the dialog pre-filled: ?tab=receivers&preset=okta lands an admin straight in the Okta form. Preset keys: okta, entra, google_risc, duo, ping, jamf, crowdstrike, generic. The Inbound signals & device posture tiles on the Integrations page use these links.

Supported transmitters​

PresetIssuer / JWKSNotes
Okta (Identity Threat Protection)https://{org}.okta.com · /oauth2/v1/keysEnter the org subdomain or custom login host
Microsoft Entra IDhttps://login.microsoftonline.com/{tenant}/v2.0 · /discovery/v2.0/keysEnter the directory (tenant) id
Google RISC (Cross-Account Protection)https://accounts.google.com · https://www.googleapis.com/oauth2/v3/certsSubject mapping iss_sub (Google sub ↔ linked identity)
Cisco Duo, Ping Identity, Jamf, CrowdStrikefree-formCopy issuer and JWKS URL from the vendor console
Genericfree-formAny SSF 1.0 transmitter

Delivery​

Standard

Push delivery and poll delivery are the two SSF delivery methods. Push is for transmitters that can reach you; poll is for those that cannot, and the receiver acknowledges each SET by jti so the transmitter can drop it. What you must do: pick the method the transmitter supports and, for poll, schedule app:ssf:poll.

  • Push — the transmitter POSTs each SET to https://app.lumoauth.dev/orgs/{org}/hooks/ssf/{streamId} with Content-Type: application/secevent+jwt and Authorization: Bearer <per-stream bearer>. The bearer is generated when the stream is created and shown once (only its SHA-256 is stored); rotate it from the portal (Rotate bearer in the receiver's actions). The endpoint answers 202 on accept, 400 {"err": "invalid_request" | "invalid_key" | "access_denied", "description": "…"} on failure, 401 authentication_failed for a bad bearer and 429 when the per-stream limiter (600 per minute) trips.
  • Poll — for transmitters that cannot push, enter their poll URL (+ bearer). LumoAuth polls it every minute, requesting up to 100 SETs (returnImmediately: true), ingests them and acknowledges (ack) or reports per-SET errors (setErrs).

Verification​

Every SET is checked in this order before anything is touched: compact JWS shape and typ: secevent+jwt; alg in RS256/RS384/RS512/ES256/ES384 (no HMAC, no none); signature against the transmitter JWKS (fetched over public HTTPS with SSRF guards, cached 15 min); iss equals the stream issuer; aud contains the stream audience (defaults to https://app.lumoauth.dev/orgs/{org}); iat within ±10 minutes; jti present and not seen before on this stream (a replay is acknowledged with 202 but not re-processed).

Subject mapping​

The stream's subject mapping says how the sub_id subject identifier (or a RISC subject) is matched to a LumoAuth user of the organization:

MappingMatches
emailsub_id.email against the user's email
subsub_id.sub against the user's external id or a linked identity (UserIdentity)
iss_sublike sub, and sub_id.iss must equal the stream issuer
phonesub_id.phone_number against the user's phone number

Agent subjects. A subject that does not resolve to a user is tried as an agent of the organization: an iss_sub subject (whose iss is the stream issuer), an opaque subject or a bare sub carrying either the public agent id (agt_…) or the identifier of an AAuth registration linked to a core agent. Revocation-class events aimed at an agent — CAEP session-revoked, credential-change, RISC sessions-revoked, credential-compromise, account-disabled — suspend the agent through the kill switch (see Agent governance); the other event types are recorded only. Reactivation stays a human decision.

A subject that resolves to neither is stored as ignored — nothing happens.

What each event does​

Event typeAction
CAEP session-revoked, RISC sessions-revokedRevoke every user session and OAuth access/refresh token (including stateless JWT access tokens: every token issued before the event is rejected by LumoAuth from then on); suspend every agent the user owns (kill switch, see Agent lifecycle); re-emit CAEP session-revoked on your outbound streams
CAEP credential-change, RISC credential-compromiseSet must change password, revoke sessions and tokens (JWT access tokens issued before the event included); re-emit CAEP credential-change
RISC account-disabledDeactivate the user, revoke everything
RISC account-enabledReactivate the user — only if the account was disabled by an SSF signal (an admin-disabled account stays disabled)
CAEP device-compliance-changeStored as an inbound risk signal and re-emitted as CAEP device-compliance-change on your outbound streams; no revocation
CAEP assurance-level-change, RISC account-purged, identifier-changedStored as an inbound risk signal (InboundSignalStore::latestForUser()) that adaptive authentication can consult; no immediate action
SSF verificationMarks the stream Verified

Every event is audit-logged (ssf.receiver.*) and listed under Recent events on the Receivers (inbound) tab for 30 days.

Replay protection. A SET is processed once per stream: a repeated jti is acknowledged with 202 but ignored, as the push delivery standard requires. The check is backed by a unique constraint on (stream, jti), so two concurrent deliveries of the same SET cannot both be processed — the second one is acknowledged as a duplicate.

Troubleshooting​

  • No Create stream / Add receiver button. You hold agents.view but not settings.manage.
  • "The delivery endpoint must be a public HTTPS URL". Outbound streams refuse private, loopback and reserved addresses (SSRF guard). Use a public hostname; tunnels such as ngrok work for testing.
  • Verification SET never arrives. Check the ssf.set_delivery_failed audit event for the HTTP status your endpoint returned; anything other than 2xx is retried with backoff.
  • Inbound SET answered 400 invalid_key. The signature could not be verified against the transmitter's JWKS (wrong JWKS URL, or the kid is not in it). Other 400 responses name the failing claim in description; a common cause is an issuer mismatch, for example an Okta custom login host entered as the org subdomain.
  • Inbound SET answered 202 but nothing happened. The subject did not resolve to a user under the stream's subject mapping; the event is stored as ignored in Recent events.

Implemented subset & notes​

  • Delivery (transmitter): push only. As a receiver LumoAuth supports both push and poll — see Receiving signals.
  • Stream management: create / read / delete plus verification. Stream update, per-stream status endpoint and the add/remove subject endpoints of the SSF Stream Management API are not implemented — streams are cheap to recreate, and all subjects of the organization are implicitly in scope of every stream.
  • Events: the CAEP event types above plus credential-change and device-compliance-change (emitted when an inbound signal forces a password change or reports a compliance change); events are only emitted when at least one enabled stream subscribes to the type (checked with a ~60s cache).
  • Auth for management: standard LumoAuth admin credentials rather than an SSF-specific scheme.

Standards used on this page​

Plain nameStandard
Shared Signals Framework (SSF) and Continuous Access Evaluation Profile (CAEP)OpenID SSF 1.0 and OpenID CAEP 1.0
Security event tokensRFC 8417
Push delivery of security eventsRFC 8935
Subject identifiers for security eventsRFC 9493
Poll delivery of security eventsRFC 8936

See the standards reference for what each one covers and how LumoAuth uses it.