# A Working Shared Signals Transmitter and Receiver in ASP.NET Core

Two applications, one event. The first revokes a user's session and announces it; the second hears the announcement and closes its own. Between them travels a signed [Security Event Token](https://www.abblix.com/en/docs/glossary-overview#set) over an HTTPS push, and by the end of this guide you will have run it.

The [Shared Signals Framework article](https://www.abblix.com/en/docs/shared-signals-framework) is the map: what SETs, SSF, CAEP and RISC are, and why they exist as separate layers. This one is the walk. Everything below runs as the `SharedSignalsSample` project in the [Getting Started repository](https://github.com/Abblix/Oidc.Server.GettingStarted), where the finished code lives; this guide explains what each piece is for and which decisions are load-bearing.

## Why the guide uses two hosts

The question you actually arrive with is how the receiver knows the event is genuine. The answer has four parts: a signature, a published key set, an expected issuer and an expected audience. Put both roles in one process and all four become the application checking its own word, which teaches nothing. So the sample pays for a second host, and every check below has something real to check.

The two roles are asymmetric in an important way. The transmitter decides what happened. The receiver decides whether to believe it. Almost all the code you write is on the believing side, and almost all of it is configuration rather than logic.

## The transmitter

### A signing key, and a key set to publish

The receiver verifies signatures with keys it fetches from the transmitter, so the transmitter needs a signing key and a public place to publish its public half. The sample mints an RSA key on startup and serves it at `/.well-known/jwks.json`; a production transmitter takes the key from wherever the deployment already keeps its keys, a vault or a certificate store.

What must survive that change is the sanitizing call on the way out. `JsonWebKey.Sanitize(includePrivateKeys: false)` returns the key's public description, its type, id, algorithm and intended use, together with the modulus and exponent, and drops the private members. That asymmetry is the whole trust model: the receiver gets enough to verify a signature and not enough to produce one.

The key id deserves its own paragraph, because getting it wrong produces a failure that points somewhere else entirely. Mint the id with the key rather than fixing it in source. A receiver caches keys by `kid` and refetches the key set when a token names one it does not already hold, so a new key wearing the previous name is not fetched on that signal at all: the receiver keeps verifying against the key it has, and every signature fails. What comes back names a key, `invalid_key`, which is also the code for any other bad signature, so the log points at the token rather than at the rotation that produced it. The section "When the key changes" walks that failure deliberately.

### Registering the services

Two registrations bring the transmitter up. `AddSecurityEvents` installs the Security Event Token machinery and takes two things from the host: which event vocabulary this transmitter speaks, through `RegisterCaepEvents` on the event registry, and where its signing key comes from. RISC is a separate vocabulary with its own `RegisterRiscEvents`; a transmitter emitting both registers both. `AddSharedSignalsTransmitter` then turns that into an SSF transmitter, taking the issuer, the address of the published key set, and the event types this transmitter is willing to send.

```csharp
builder.Services.AddSecurityEvents(options =>
{
    options.Events.RegisterCaepEvents();
    options.SigningKeySource = _ => Task.FromResult<JsonWebKey>(signingKey);
});

builder.Services.AddSharedSignalsTransmitter(new SharedSignalsTransmitterOptions
{
    Issuer = issuer,
    JwksUri = new Uri($"{issuer}/.well-known/jwks.json"),
    EventsSupported = [CaepEventTypes.SessionRevoked],
    AllowedReceiverAddresses = [.. streams.Select(stream => stream.PushEndpointUrl).OfType<Uri>()],
});

builder.Services.AddSharedSignalsConfiguredStreams(streams);
```

One of its options exists because of a specific attack, and is worth understanding before you meet it. Private and loopback destinations are refused by default, and `AllowedReceiverAddresses` is how an operator permits the ones that are genuinely theirs. The refusal is not arbitrary caution: a receiver names its own delivery endpoint, so that address is input from outside, and a transmitter that POSTs wherever it is told makes requests from inside your network on a stranger's instruction. Nothing relays the response back, so this is a write primitive rather than a way to read your metadata service, which is quite enough where an internal endpoint acts on a POST.

The check is worth knowing precisely, because a reviewer will ask. An allowed entry matches by origin, scheme, host and port together, rather than by prefix or wildcard. The judgement is made again before every delivery rather than once at configuration time, a hostname that resolves to a private address is refused on the resolved address and not merely on its spelling, and redirects are not followed to somewhere the allowlist never approved. Both halves of this sample run on `localhost`, which is exactly the case the refusal covers.

Where that list comes from is worth copying. The sample derives it from the delivery endpoints of its own declared streams rather than configuring it separately, so the address the transmitter permits and the address it pushes to are one value. Configured apart, they can be edited apart, and a transmitter that allow-lists one host while delivering to another fails in a way that reads as a network problem.

### Declaring the stream

A receiver normally creates its stream through the transmitter's Stream Management API. When the receivers of a deployment are known in advance, `AddSharedSignalsConfiguredStreams` takes them from configuration instead, which is less machinery for the same result. A declared stream names the receiver, the event types it wants, the audience the tokens are addressed to, the URL to push to, and whether the stream covers all subjects or only those added explicitly.

Read the `SharedSignals:Streams` section as required rather than defaulting it to an empty list. A transmitter whose stream configuration was renamed or lost would otherwise start cleanly, deliver nothing and log nothing, which is the emptiest kind of failure to diagnose.

### Where the queue lives, and what a second instance breaks

The dispatcher queues an event for each stream that wants it, and the queue, the stream store and the lease that decides who sweeps are all in memory by default. That is the honest default for one process and the wrong one for a deployment, in two ways that no log will tell you about.

Undelivered events die with the process, so a restart while anything is queued loses those events outright. And the default lease reaches only inside one process, so every instance believes it holds every stream: put the transmitter behind two replicas and each of them sweeps the same queue and POSTs the same event, which is the duplication a receiver then has to absorb. `AddSharedSignalsRedisOutbox` makes the queue survive a restart, and `AddSharedSignalsRedisDeliveryLease` divides the streams between instances instead of duplicating them. A single-instance transmitter can stay on the defaults; anything that scales out cannot.

A receiver that is down does not lose events immediately. The transport failure ends that stream's pass and the queue keeps its order for the next one, which is the behavior you want. It also keeps growing: the outbox has no bound and no expiry, so a receiver that stays down is a queue that stays up, and with the in-memory outbox both vanish when the transmitter restarts. Decide before you ship how deep a queue is too deep, and what you do about a stream that has accepted nothing for an hour.

Declaring the streams also means this transmitter never needs the management API, and the sample does not map it. That is a decision rather than a formality. `MapSharedSignalsTransmitterEndpoints` maps the management API and the configuration document together, and whoever can create a stream can ask to be told about your users, so the API has to be guarded by scope. `SharedSignalsEndpointOptions.GrantedScopesSelector` is where a host says how to read what a caller's token was granted, and left unset there is no scope check at all: every authenticated caller may do everything. The library warns about that at startup. The warning is raised where the transmitter advertises itself rather than where the management routes are mapped, so this sample raises it too, with no management API behind it: read it there as a reminder that the selector is unset, not as a report that the API is exposed. A transmitter that only needs to advertise itself maps `MapSharedSignalsConfigurationDocument` alone, as the sample does.

Which leaves the document itself worth reading once, because it speaks to strangers. It is the only artifact of the pair a receiver parses by machine, and it names the management addresses whether or not they are mapped: they are built from the route prefix rather than from what answers, so a transmitter that maps the document alone advertises five addresses that return 404. Nothing in the pair notices, and nothing can be set to silence it.

Two of its other members are the host's to get right, and both default to describing a transmitter you are probably not. `AuthorizationSchemes` left unset advertises OAuth 2.0, which announces a guarded management interface even on a host that authenticates nobody; an empty list is how the library is told to advertise none. `DefaultSubjectsMode` publishes what a stream created through the management API would cover, and its default says none, which contradicts a settings file whose declared stream covers all subjects. Set both, and what the document claims about this transmitter narrows to what is true of it.

### Dispatching an event

Everything above is setup. The part that runs when something actually happens is a single `EventDispatcher.DispatchAsync` call, placed where your code ends the session. It takes the event type, the subject the event is about, and the event's own payload; the dispatcher works out which streams asked for that event and queues it for each, and delivery happens on the sweep.

```csharp
await dispatcher.DispatchAsync(new SecurityEventDescriptor
{
    EventType = CaepEventTypes.SessionRevoked,
    Subject = new ComplexSubject
    {
        Session = new OpaqueSubject(sessionId),
        User = new EmailSubject(user),
    },
    Payload = new SessionRevokedPayload
    {
        InitiatingEntity = CaepEventPayload.InitiatingEntities.Policy,
    },
}, cancellationToken);
```

The subject is where a decision hides. SSF's `ComplexSubject` carries coordinated members, and the sample names both a session and a user, because the two mean different things on the receiving side. Naming the session says this one login is over. Naming only the user says every session that person has with you is over. A transmitter that flattens both into a single identifier has thrown the distinction away, and the receiver cannot recover it.

## The receiver

### Trust comes from the key set, not from the connection

`AddJwksKeyResolution` points the receiver at the transmitter's published key set, and this is the receiver's trust root. Note what it is not: it is not a check on where the POST came from. An event is believed because it verifies against a key published by the issuer the receiver expects. A delivery arriving from an unexpected address with a valid signature is equally good, and one arriving from the right address with a bad signature is worthless.

Which is not a reason to leave the endpoint open. Nothing in this pipeline authenticates the caller: `MapPushDeliveryEndpoint` maps a route with no authorization of its own, and the issuer allowlist admits any token naming the transmitter, which is a public value. So anyone who can route to the endpoint can make the receiver parse and verify, and each token naming an unknown key id costs one outbound fetch against the transmitter. The ratio is one fetch per token and the target is the party the receiver trusts most, which is why the cooldown described under "When the key changes" exists and why the endpoint belongs behind whatever the deployment already has: mutual TLS, a credential the transmitter presents, or a network boundary. What such a caller cannot do is get an event accepted, because the signature still has to verify.

### The validation profile

`AddSharedSignalsReceiver` takes the expectations the profile enforces. Two are familiar: the issuers this receiver will listen to, and the audience the tokens must be addressed to. Dropping the audience check is the classic mistake, because it is exactly what stops an event legitimately issued for somebody else from being replayed at you.

The third is the one people miss, and nothing defaults it. `StreamIssuer` names the issuer of the stream the events arrive on. [SSF 1.0](https://openid.net/specs/openid-sharedsignals-framework-1_0.html) Section 4.1.6 requires `iss` to match both the Stream Configuration's issuer and the issuer whose Transmitter Configuration the receiver read, and the receiver proved those two equal when it accepted the stream, so one value carries both halves of the rule. Here the sample has one stream from one transmitter and the values coincide; a receiver holding several streams carries one profile per stream. Nothing checks it at startup: left unset, the host comes up clean and the first token to arrive throws, answering 500. It is worth asserting at startup yourself rather than discovering it under load.

```csharp
builder.Services.AddSharedSignalsReceiver(new SharedSignalsValidationOptions
{
    ExpectedIssuers = [transmitter],
    ExpectedAudience = self,
    StreamIssuer = transmitter,
});
```

The rest of the profile is standard and automatic: the signature, the `jti` that [RFC 8417](https://datatracker.ietf.org/doc/html/rfc8417) makes REQUIRED of every SET, the absence of `exp` that keeps a SET from ever passing as an [ID token](https://www.abblix.com/en/docs/glossary-overview#id-token-identity-token), and a freshness window on `iat`.

### Duplicates are the sink's problem

[RFC 8935](https://datatracker.ietf.org/doc/html/rfc8935) lets a transmitter send the same event more than once regardless of the response it got the first time, so duplicates are ordinary traffic rather than a sign that something failed.

`AddDistributedReplayCache` records what this receiver accepted, keyed on the issuer and the `jti` together, so a host can see what it consumed. It does not short-circuit a repeat, and the order is deliberate: the reservation is written only after your sink has accepted the event, because an entry written first would stand even when the sink refused, and the transmitter's retry would then be answered 202 with nobody having seen the event. A cache that can reserve but not release has no safe way to undo that.

So a duplicate reaches `ConsumeAsync` again, every time. Idempotency in your sink is not a second line here; it is the only one.

Order deserves the same caution. A SET carries no sequence number, and delivery order is preserved only within one stream's pass. Decide state from what each event says, together with its event timestamp, rather than from which one landed first.

### The one class you write

Everything else in this guide is registration. `ISecurityEventSink` is the single interface the application implements, and its `ConsumeAsync` receives a `ValidatedSecurityEventToken` whose name states what has already happened: signature, issuer, audience, freshness and `jti` were all decided by the profile before your code saw it. This method is where the event stops being a token and becomes a decision in your own domain, and in the sample that decision is to record the revoked session identifier.

Returning null accepts the delivery, and the receiver answers 202 only after this method has returned. Returning a `DeliveryError` answers 400 instead, and the code you choose decides the event's fate: the transmitter drops it from its queue unless the code is `access_denied` or `authentication_failed`, the two it reads as conditions an operator can put right. An unrecognised code counts as final too. So a sink that simply cannot act right now, because its database is down or its queue is full, must not answer with a `DeliveryError` at all: fail the request instead, and the transport error leaves the event queued for the next pass.

```csharp
public sealed class SessionStore : ISecurityEventSink
{
    public Task<DeliveryError?> ConsumeAsync(
        ValidatedSecurityEventToken token,
        CancellationToken cancellationToken = default)
    {
        var session = (token.Token.GetSubjectId() as ComplexSubject)?.Session as OpaqueSubject;

        // ... act on it ...

        // Accepts the delivery; the receiver answers 202 once this returns.
        return Task.FromResult<DeliveryError?>(null);
    }
}
```

Two cases the sample does not handle, and a deployment must. An event naming only the user, with no session, means every session that person holds with you, so the sink has to enumerate and close them; the sample acts only on the session member and accepts a user-only event without doing anything. And an event whose subject matches nothing you hold is ordinary rather than exceptional: the identifier vocabulary is agreed between the two organizations before any stream exists, and a mismatch shows up as deliveries that are accepted and change nothing. Whether an unmatched subject stays a silent 202 or becomes something an operator sees is your decision, and worth making deliberately.

The route the deliveries arrive on is the receiver's to choose, and `MapPushDeliveryEndpoint` maps it; whatever path you pick is the one the transmitter's stream must name.

## Running the pair

The pair is two ASP.NET Core applications started side by side: a Shared Signals transmitter that signs and pushes a CAEP session-revoked event, and a receiver that verifies it and closes the session. They come from the `SharedSignalsSample` project of the Getting Started repository, and each one references its own packages:

```shell
# the transmitter
dotnet add package Abblix.SharedSignals
dotnet add package Abblix.SharedSignals.MinimalAPI
dotnet add package Abblix.SecurityEvents.CAEP
dotnet add package Abblix.JWT

# the receiver
dotnet add package Abblix.SharedSignals
dotnet add package Abblix.SecurityEvents.MinimalAPI
dotnet add package Abblix.SecurityEvents.CAEP
```

A receiver of Back-Channel Logout alone needs neither `Abblix.SharedSignals` nor a stream: `Abblix.SecurityEvents` with its Minimal API adapter is the whole dependency, which is the smaller integration the [Shared Signals Framework article](https://www.abblix.com/en/docs/shared-signals-framework) maps out.

The sample listens on `https://localhost:5101` for the transmitter and `https://localhost:5102` for the receiver. The push is a real HTTPS request between two processes, and the receiver's fetch of the key set is another, so the development certificate has to be trusted before either starts:

From the root of the cloned repository, first trust the certificate:

```shell
dotnet dev-certs https --trust
```

Then start the receiver, and leave it running:

```shell
dotnet run --project SharedSignalsSample/ReceiverApp
```

And the transmitter, in a second terminal:

```shell
dotnet run --project SharedSignalsSample/TransmitterApp
```

Wait for `Now listening on: https://localhost:5102` and `Now listening on: https://localhost:5101` rather than assuming the ports came up, because a process that failed to start leaves a port quiet in exactly the same way a slow one does. The transmitter also prints `Sweeping push streams every 00:00:30, ...`, which is how often it looks for something to deliver.

Now ask the receiver what it knows, and revoke a session:

```shell
curl -k https://localhost:5102/revoked-sessions
# []
curl -k -X POST "https://localhost:5101/sessions/alice-laptop-session/revoke?user=alice@example.com"
# 202
```

Delivery happens on the next pass, so give it up to thirty seconds before asking again:

```shell
curl -k https://localhost:5102/revoked-sessions
# ["alice-laptop-session"]
```

Read the first, empty answer as part of the test rather than as a formality. Without it, the last line is equally consistent with a store that always said `alice-laptop-session`, and the run proves nothing about delivery.

The `202` from the revoke call is the transmitter accepting the event, not the receiver getting it. The receiver's verdict shows up in the transmitter's log as the status of its own outbound POST, and a `202` there means the token was validated and the sink ran to completion. Accepted is not the same as acted on: a sink that recognizes nothing in the event answers 202 as well.

A `400` means the receiver rejected it, and the log line carries the receiver's own error code, which is the one thing worth alerting on. A warning carrying an exception whose message begins `Refusing to deliver` means the transmitter never sent anything at all, with the reason in the rest of that message. Any other status, and a connection failure too, leaves the event queued in order for the next pass rather than dropping it.

## When the key changes

This is the failure worth producing on purpose once, in a sample, rather than meeting for the first time in a deployment.

Read the `kid` from the transmitter's key set, restart the transmitter, and read it again. It changed, because the sample mints a key per run and mints the id with it. Revoke another session and it arrives: the receiver met a `kid` it did not hold, refetched the key set, and verified against the new key.

Now fix the key id to a constant and restart again. Deliveries start failing, and the transmitter logs the receiver's verdict as `invalid_key`. That names the symptom rather than the cause: it is equally what a tampered payload or a wrong algorithm looks like. The cause is that the receiver caches by key id, so a new key wearing the old name is one it is confident it already holds and never refetches.

Leave it running and it heals on its own, which is the confusing part. The forced refetch is not the only path back to the issuer: the cached set also has an ordinary lifetime, `JwksKeyResolutionOptions.CacheLifetime`, fifteen minutes by default, and when that expires the receiver refetches for its own reasons and picks up the new key under the pinned name. So the outage lasts until the cache lifetime runs out, and every event refused inside it is already gone.

That exercise runs instantly in the sample only because the sample disables a rate limit a deployment needs. `RolloverRefetchCooldown` puts a floor of thirty seconds under the forced refetch, for the reason given under the receiver's trust root: the push endpoint authenticates nobody, so without the floor each token naming an unpublished key id would cost one fetch against the transmitter.

Keep that floor, and know what it costs at a rotation. Inside the window a token signed by the new key is judged against the old key set and refused with `invalid_key`, and a refusal about the token itself is acknowledged out of the transmitter's queue rather than retried, so those events are gone. Only a refusal about the transmitter's own standing, `access_denied` or `authentication_failed`, leaves the event queued, because that is the kind an operator can put right without the event changing. The floor is the same order as the default delivery pass, which makes the window easy to land in.

None of which you have to accept, because the loss is avoidable rather than inherent. Publish the new public key in the key set before anything is signed with it: the receiver picks it up on an ordinary cache refresh, and the first token naming the new `kid` finds a key it already holds. Rotate by publishing first and switching second, and neither the cooldown nor the cache lifetime ever bites.

The lesson generalizes past this sample. A key id is not a label for the slot a key sits in; it is the name of that key, and it changes when the key does. Any rotation scheme that reuses the id produces exactly this outage, on a delay, with a symptom that points at signatures rather than at rotation.

## What to check before you call it working

- Point the receiver at a different transmitter address and restart it: deliveries should stop being accepted. The reason is worth being precise about, because it is not the signature. The token's `iss` is no longer an issuer this receiver expects, and the profile checks that before it does any signature work.
- Confirm the audience is enforced: point the receiver's expected audience at something the transmitter does not address its tokens to, and deliveries should be refused.
- Restart the transmitter and deliver again. In the sample this is instant; in a deployment allow for the rollover cooldown you keep, or rotate by publishing the new key first and skip the wait entirely.
- Deliver the same event twice: your sink must reach the same state, because it will see both deliveries.

## Knowing it is still working

Silence is ambiguous. No events at all, a stream nobody sweeps, a queue that vanished with a restarted process and a receiver accepting everything while matching nothing all look identical from outside, and none of them raises anything.

SSF answers this with the Verification Event: a receiver asks the transmitter to send one and confirms the round trip, throttled by the stream's own minimum interval. The library carries it on both sides, and it is the only signal that exercises the whole path rather than a part of it.

Two things the transmitter already logs are worth alerting on beside it. A refusal summary names the receiver's own error code, so a sustained rate of `invalid_key` says a rotation went wrong and a sustained `invalid_request` says the two sides disagree about the envelope. And a `Refusing to deliver` warning means the transmitter is declining an address, which is a configuration answer rather than a network one.

## Where to go next

- [Shared Signals Framework](https://www.abblix.com/en/docs/shared-signals-framework): how SSF, CAEP, RISC and Back-Channel Logout relate, and which package covers which layer.
- [Getting Started repository](https://github.com/Abblix/Oidc.Server.GettingStarted): the `SharedSignalsSample` project, runnable.
