How Identity Systems Deliver Bad News: Back-Channel Logout, SET, and the Shared Signals Framework
TL;DR
Back-Channel Logout was the prototype: one provider, one event, one HTTP POST. RFC 8417 generalized the envelope into the Security Event Token (SET). RFC 8935 and RFC 8936 standardized delivery and deliberately left the hardest question out of scope: who gets what, and where. The OpenID Shared Signals Framework (SSF) answers exactly that question with event streams, and CAEP and RISC supply the vocabularies. This article is the map of how these specs relate, and how they landed in the Abblix package family for .NET - including why a client that only wants logout notifications never has to hear about streams at all.
Availability: the packages named below ship with Abblix OIDC Server 2.4 and are not on nuget.org yet. Until then the source is in the repository under src/Abblix.SecurityEvents and src/Abblix.SharedSignals, and every API shown here is the code as it stands there.
The Problem: the Only Party Who Knows Cannot Tell Anyone
A user signs in to your application through an identity provider, and from that moment your session lives its own life. The provider may terminate the user's session, an administrator may disable the account, a credential may show up in a breach dump - and your application keeps serving the session it established an hour ago, because nothing in the original OAuth and OpenID Connect design carries news backwards.
Tokens expire, which is a blunt answer: shorten lifetimes and you trade user experience for a smaller window, lengthen them and the window grows. The relying party can also ask - introspect a token, fail a refresh - which is asking on an interval, and about tokens rather than the sessions built on top of them. Nothing in the core protocols pushes the other way. What is actually missing is an event: a way for the party that learned something security-relevant to tell the parties that care, promptly and verifiably.
Everything below is the standards world building exactly that, one layer at a time.
Back-Channel Logout: the Prototype That Already Worked
OpenID Connect solved the narrowest version of this problem years before the general frameworks existed. Back-Channel Logout 1.0 is one provider telling one relying party that one session ended: an HTTP POST with a signed Logout Token to a URL the client registered as backchannel_logout_uri.
Notice how many questions this design never has to ask. Who is the counterparty? Known - the RP registered as an OIDC client. Which events? Exactly one, logout. Where to deliver? The URI is in the client's registration metadata. Which subjects? The sub and sid the two parties already share through the session itself. The entire management half of the problem is answered by OIDC client registration, which both sides had done anyway.
Keep that observation - it explains the rest of the article.
Security Event Token (SET): the Envelope Grows Up
The Security Event Token (RFC 8417, July 2018) generalizes the Logout Token's shape into a reusable envelope: a JWT whose events claim carries a statement about something that happened to a subject. The RFC defines the envelope and pointedly refuses to define any events: what an event means, what claims it carries, and how a recipient validates it are delegated to "profiling specifications".
The envelope carries one rule that looks pedantic until you run two token kinds through one pipeline. A SET travels through the same infrastructure as ID Tokens, signed by the same keys, and RFC 8417 Section 4.1 devotes a section to making sure one can never be accepted as the other. Its rule: a SET profile that could be confused with an ID Token MUST NOT carry an exp claim. Since exp is required in ID Tokens, every conformant ID Token implementation rejects such a SET automatically.
Back-Channel Logout takes the route the RFC itself names as sufficient: it PROHIBITS nonce - the claim that binds an ID Token to its authentication request - so a Logout Token can never pass as an ID Token, and it separately REQUIRES exp, so a captured one stops being usable. The Shared Signals profile takes the other route and forbids exp outright.
Two profiles over one envelope, two opposite rules about the same claim, and no single set of validation rules satisfies both. Neither side is wrong, and a host that serves both token kinds has to keep the two validations apart rather than merge them. How these packages do that is below.
The Transport for SETs, and a Gap Left on Purpose
Two delivery methods for SETs arrived in November 2020. Push (RFC 8935): the transmitter POSTs each SET to the receiver's endpoint as application/secevent+jwt and expects an empty 202. Poll (RFC 8936): the receiver fetches batches and acknowledges them by jti, which suits receivers behind firewalls that cannot expose an inbound endpoint at all.
Read RFC 8935's introduction closely and you find the sentence that makes the next specification necessary: exchanging configuration metadata such as endpoint URLs and cryptographic keys "is out of scope for this specification". The transport RFCs deliberately do not answer who receives which events about which subjects, where the endpoint lives, or how any of it was agreed. For Back-Channel Logout none of that was a problem - OIDC registration had answered it. For everyone else, the gap was now standardized.
Shared Signals Framework: the Management Half
The OpenID Shared Signals Framework is the specification that fills that gap. Its central object is the event stream: a receiver finds a transmitter through a well-known document (/.well-known/ssf-configuration) and creates a stream naming the event types it wants and the delivery method it prefers. From then on the stream is the relationship: the receiver adds and removes the subjects it cares about, pauses and resumes delivery, and can demand a verification event to prove the pipe is alive.
The transmitter advertises what it supports, and a stream never carries more than the intersection of what the receiver asked for and what the transmitter has.
SSF itself defines almost no events either. The vocabularies ride on top: CAEP (Continuous Access Evaluation Profile) covers session and access lifecycle - session revoked, token claims changed, assurance level changed, device compliance changed - and RISC (Risk Incident Sharing and Coordination) covers account-level incidents - account disabled, credential compromised, recovery information changed.
Subjects are where cross-domain reality bites. RFC 9493 defines the formats a subject travels in - an email address, a phone number, an issuer-and-subject pair, an opaque handle, a set of aliases for one subject - because the receiver names subjects in whatever vocabulary it has, and the transmitter must match that against its own accounts. The ComplexSubject in the dispatch sample below is SSF's own addition on top of them (Section 3.3): one subject principal named through several coordinated members - a user, and one of that user's sessions - each member itself an RFC 9493 identifier.
Matching is where the work stays yours. SSF Section 8.1.3.1, which is what the transmitter here implements, is equality rather than resolution: a field absent on either side acts as a wildcard, so a receiver that added only a tenant hears about every user of it, while everything present must be identical.
A receiver that adds a subject in email format hears nothing from a transmitter that emits iss_sub about the same person, and that silence carries no error. Agree the format with each counterparty, and resolve to the transmitter's vocabulary before adding a subject to a stream.
The Family Tree
One warning before the diagram: the tree shows what builds on what, not what came first, and only the arrows into Back-Channel Logout and into the vocabularies are profiling in RFC 8417's sense. The middle two say that transport carries any SET, and that SSF completes what transport left open. Back-Channel Logout's design predates the RFC it sits under - RFC 8417 itself uses the logout event as its worked example.
Back-Channel Logout is not a special case of SSF. They are parallel branches over the same envelope, and the difference is how each answers the questions the core leaves open:
| who answers "to whom, what, where" | Back-Channel Logout | Shared Signals |
|---|---|---|
| where the counterparty comes from | OIDC client registration | the receiver creates a stream via API |
| which events | one, fixed by the profile | at most the intersection of requested and supported |
| where to deliver | backchannel_logout_uri from client metadata | the stream's push endpoint, or poll by stream identifier |
| which subjects | sub / sid from the shared session | subjects added and removed per stream |
| discovery | not needed - OIDC already has it | /.well-known/ssf-configuration |
| ID Token confusion defense | nonce prohibited (exp required - bounding the token's lifetime) | exp must be absent, unconditionally (SSF 1.0 Section 4.1.7) |
Read the table right to left and an RP that registered its logout URI years ago looks like a stream frozen at creation. The resemblance is worth having; the lineage is still the one the diagram shows. SSF exists because outside of OIDC there is no registration to piggyback on: a device management system telling an identity provider that a laptop fell out of compliance has no client registration with that provider, and needs streams precisely because nothing else answers the management questions for it.
That example also shows the direction is free: either side of any security relationship can transmit, where a logout token only ever flows provider to application.
Why We Took This On
Two products we are building sign their users in through our own identity provider: one faces consumers and their bookings, the other administers tenants for businesses. The moment the provider ends a session or an administrator disables a tenant, both applications are holding state that just went stale - the exact gap this article opened with, sitting in our own backlog.
That deployment also explains a path in these packages that a spec reading would not predict: stream sets declared in configuration, receivers that are the operator's own products. When every receiver is known in advance, the streams between them are settings rather than API calls. The management API stays the primary path and that mode is an addition on top of it.
It also sets the limit of what we can claim. Both ends are ours so far, which proves the wiring and says nothing about the protocol, and none of these packages is certified: the OpenID Foundation's Shared Signals conformance suite is at alpha and covers the receiver role only, so transmitter certification is available to nobody today. Interoperability with your own provider is something you will have to verify yourself.
How This Maps to Packages
We implemented every box in that tree above RFC 7519 - both delivery methods, both roles, and the CAEP and RISC vocabularies in full - and the package boundaries follow the specification boundaries deliberately:
Abblix.SecurityEventsis RFC 8417 plus both delivery RFCs, the RFC 9493 subject formats, and the Back-Channel Logout receiving profile: what a SET is, whom it is about, how one is validated, how one travels. No stream management in it: nothing in this package creates, pauses or enumerates a stream.Abblix.SharedSignalsis SSF: streams, their management API, subject bookkeeping, transmitter and receiver roles, discovery.Abblix.SecurityEvents.CAEPandAbblix.SecurityEvents.RISCare the vocabularies - typed payloads and event type constants that register into the same event registry.- The
.MinimalApiadapter of each core maps its endpoints onto ASP.NET Core routes, andAbblix.SharedSignals.Rediscarries the storage and coordination a scaled-out transmitter needs.
All seven are Apache-2.0 and target net8.0, net9.0 and net10.0. They depend on Abblix.Jwt, also Apache-2.0, and on nothing commercially licensed - a relying party can take the Back-Channel Logout receiver without any relationship with us.
The split has one consequence we consider a feature: the most numerous consumer in this whole story is an ordinary relying party that wants logout notifications - from any conformant provider, not only ours - and that consumer installs Abblix.SecurityEvents plus Abblix.SecurityEvents.MinimalAPI and never sees a single stream-related type:
builder.Services.AddSecurityEvents();
builder.Services.AddJwksKeyResolution(options => { /* where the provider publishes its keys */ });
builder.Services.AddDistributedMemoryCache(); // per-pod: a replayed token rejected by one pod sails through the next
builder.Services.AddBackChannelLogoutReceiver(new BackChannelLogoutValidationOptions
{
ExpectedIssuers = ["https://op.example.com"],
ExpectedAudience = "this-client-id",
});
// The part only the application can write: which sessions to end. A notification may name
// one session (sid), or a user with no sid - which means every session that user has here.
builder.Services.AddSingleton<ILogoutNotificationSink, MySessionStore>();
app.MapBackChannelLogoutEndpoint("/backchannel-logout");Key resolution is the receiver's actual trust root: keys are fetched from the provider's published JWK Set and cached, and a token naming a kid the cache lacks forces one refetch - how a key rotation is noticed the moment it matters, rate-limited so a flood of forged kid values cannot turn your validator into a JWKS-hammering client.
Remember the two opposite exp rules from earlier? They are also why validation runs as named profiles rather than one shared pipeline. A host serving both token kinds would otherwise have its logout validator demand the very claim the Shared Signals profile refuses - with named profiles each consumer owns its copy of the pipeline, and registration order stops deciding who wins.
A transmitter is a registration chain and one mapping call, and emitting an event is one dispatcher call at the place where the thing actually happened:
builder.Services
.AddSecurityEvents(options => options.SigningKeySource = _ => Task.FromResult(signingKey))
.AddSharedSignalsTransmitter(new SharedSignalsTransmitterOptions
{
Issuer = "https://idp.example.com",
JwksUri = new Uri("https://idp.example.com/.well-known/jwks.json"),
EventsSupported = [CaepEventTypes.SessionRevoked],
PollEndpointFactory = streamId => new Uri($"https://idp.example.com/ssf/poll/{streamId}"),
});
// "ssf-receivers" is the host's own authorization policy - see 'Nobody Delivers to
// Strangers' below on who may create a stream.
app.MapSharedSignalsTransmitterEndpoints().RequireAuthorization("ssf-receivers");JwksUri is how receivers find the key your SETs are signed with; leave it null only where key distribution is agreed out of band, and expect a conformant receiver to refuse the stream otherwise - ours does. The mapping call registers the management surface and poll delivery under SharedSignalsEndpointOptions.ManagementPrefix, /ssf by default. The factory exists because only your deployment knows the public origin in front of that prefix, so change one and change the other.
await dispatcher.DispatchAsync(new SecurityEventDescriptor
{
EventType = CaepEventTypes.SessionRevoked,
Subject = new ComplexSubject { Session = new OpaqueSubject(sessionId), User = userSubject },
Payload = new SessionRevokedPayload { InitiatingEntity = CaepEventPayload.InitiatingEntities.Policy },
});The dispatcher fans the event out to every stream whose subject and event type match, mints a signed SET per stream, and queues it for that stream's delivery method.
The Parts a First Integration Never Exercises
Reading the specifications gets you the happy path. A few decisions in the implementation exist because of what happens after the first integration works.
Running More Than One Instance
Push delivery is the transmitter reaching out, so something sweeps the queues on a timer - and a hosted service runs on every instance of an application. Without coordination, N instances read the same pending events and each POSTs them, which RFC 8935 warns a transmitter away from: it "SHOULD NOT retransmit a SET" unless it suspects a recoverable failure, and instances duplicating each other's work suspect nothing.
So a stream is claimed before it is swept, the claim expires (expiry is the only release an instance that died mid-pass can perform), and the pass is cut off at the claim's own deadline.
The shipped defaults are single-instance and say so in their names, and replicas need three swaps. AddSharedSignalsRedisStreamStore so a stream created through the management API is visible whichever instance the next request reaches, instead of returning 404 from the other two. AddSharedSignalsRedisOutbox because the in-package queue is read-modify-write, so one replica's enqueue overwrites another's and the loss is silent. AddSharedSignalsRedisDeliveryLease so one instance sweeps a stream at a time. The transmitter logs which of each it wired at startup, and two replicas both logging the process-local lease are two transmitters, not one.
The Receiver Names Its Own Delivery Address
A push endpoint URL arrives from outside and can point anywhere, including inside your network - and a name that was public when the stream was created can start resolving to something internal by the time an event is delivered. The address policy is therefore re-checked before every send, redirects are refused, and the check sits on the HTTP client's primary handler, underneath whatever resilience pipeline the host adds, so no retry policy can route around it.
Nobody Delivers to Strangers
The stream management API is an OAuth-protected resource - the RequireAuthorization("ssf-receivers") in the transmitter sample is where your deployment decides who may create a stream against it.
On the delivery side, the SET's signature is what proves an event's origin, and a push receiver may hand the transmitter an authorization header at stream creation to present on every POST, so unsolicited traffic dies before any JWT parsing. The relying party's back-channel logout endpoint authenticates the message rather than the caller for the same reason: the signed Logout Token is the credential - do not put your session-cookie authentication in front of it.
Redelivery Is Legal, Order Is Not Promised
RFC 8935's "SHOULD NOT retransmit a SET" carries an unless-clause, so redelivery is legal, and the same RFC requires the receiver to "respond as it would if the SET had not been previously received". Duplicates are therefore ordinary traffic, and the receiving sink - the ISecurityEventSink counterpart of the logout sink above - is written idempotent first.
The replay cache is the second line. It is opt-in for the push receiver, since the RFC obliges that receiver to tolerate a repeat anyway, and built into the logout receiver, where the profile names jti checking as an option. Either way it is consulted only after a token passes validation, so a rejected token can never burn an identifier that a legitimate delivery will present later.
Order is the other half. SSF makes one promise, for the paused case: successive events about the same subject principal are released in the order they were generated (Section 8.1.2.1), and the transmitter here holds them in the outbox in exactly that order.
Nothing promises anything beyond it, and idempotence does not stop a stale event landing after a newer fact about the same subject. CAEP and RISC events MAY carry event_timestamp, so a sink compares it where it is present and falls back to its own receipt order where it is not. A logout sink that ends the named sid when the notification carries one, and every session of that sub only when it does not, never ends more than the notification claimed.
Storage Tiers Are Yours to Decide
RFC 8935 leaves delivery reliability to the implementer's own requirements, RFC 8936 lets a transmitter discard undelivered SETs under deployment-specific conditions, and SSF lets it drop events held for a paused stream - a stream is a signal, not a system of record. So the outbox is deliberately not durable: losing it drops pending events, which is the gap a receiver of signals must survive anyway, and losing a delivery claim costs a duplicate the receiver is already required to tolerate.
That reasoning covers losing the whole store. It does not cover losing one write between instances. The shipped default is in-process; AddSharedSignalsDistributedOutbox moves it onto IDistributedCache, and that implementation is read-modify-write, correct for one instance only. On replicas take AddSharedSignalsRedisOutbox, whose mutations compose server-side. Without it, concurrent enqueues overwrite one another, and that loss is silent. Where an event carries an obligation the signal model does not cover, the outbox is one interface - IEventOutbox - and a durable implementation of your own replaces the shipped ones the same way the Redis one does.
Stream registrations can live in the application's configuration for closed deployments where every receiver is your own product; on startup the declared portion is written over the store while what the receiver owns through the management API - its status, its subjects - is carried over rather than rebuilt.
Where to Start
The packages named below ship with Abblix OIDC Server 2.4 and are not on nuget.org yet; until then they are source in the repository.
If you operate a relying party: implement Back-Channel Logout first. It is the smallest piece and it closes the oldest gap, the session that outlives its owner. You need Abblix.SecurityEvents and Abblix.SecurityEvents.MinimalAPI, plus an IDistributedCache implementation once you run more than one instance.
Spend thirty seconds on your provider's discovery document before you start. backchannel_logout_supported tells you the POST arrives at all; backchannel_logout_session_supported tells you whether it will name a session, and without that second flag every notification names only the user, so your sink ends all of that user's sessions with you. Both flags describe what the software can do, and neither describes how a given deployment is configured: ours advertises the first whenever logout notification is registered at all. Confirm with a real logout.
If you operate an identity provider or a security product that learns things others should know: that is what the transmitter role is for, and CAEP's session events are the vocabulary your relying parties can act on immediately. That is Abblix.SecurityEvents as the core, Abblix.SharedSignals with its MinimalApi adapter, and Abblix.SecurityEvents.CAEP for the vocabulary, with Abblix.SharedSignals.Redis once the transmitter runs on replicas. Budget for what the role costs: a sweeper on every instance, a Redis once there is more than one, and an authorization policy you write for stream creation.
If you are somewhere in between - most real systems are both a provider to someone and a client to someone else - install the union of the two lists above over one core. A single AddSecurityEvents serves both roles: give it the signing key for the transmitter side and AddJwksKeyResolution for each issuer you receive from. The two validation profiles are named and independent, so a logout receiver requiring exp and a Shared Signals receiver refusing it coexist in one host without registration order deciding anything.
From the logout prototype to the finished vocabularies, the specifications converged slowly on something this simple in shape: one envelope, two transports, one management layer, and vocabularies on top. The shape is simple; running the delivery pipeline underneath it is where the decisions are. That is why the packages cut where the specs cut, and why the smallest consumer stays small.