Shared Signals
With op.WithSharedSignals() the OP becomes a Shared Signals Framework (SSF) 1.0 transmitter. It tells other systems about security events concerning your users, such as "this user's session ended" or "this account was disabled", as signed Security Event Tokens (SETs, RFC 8417). A receiver is an ordinary OAuth client of this OP that has been allowed to listen. The OP does not receive events from other transmitters.
The default is off. Without the option no endpoint, worker or scope is created.
Enabling
op.New(
/* required options */
op.WithStore(myStore), // must implement store.SharedSignalsStore
op.WithCookieKeys(currentKey, previousKey),
op.WithSharedSignals(),
)op.New refuses to start unless all of the following hold:
- The store implements
store.SharedSignalsStoreand bothSSFStreams()andSSFEvents()return a real substore. Store.Grants()implementsstore.GrantClientLister. The OP uses it to find which clients a subject has a grant for.WithCookieKeysis set. Theauthorization_headera receiver supplies for push delivery is sealed with AES-256-GCM under a key derived from the cookie key ring, so it is never stored in the clear. Rotating the ring the usual way keeps sealed headers readable: the current key is tried first, then the previous ones. A header that no key in the ring opens is reported asssf.push_abandonedwith reasonseal, and the receiver mustPUTits stream again.- No scope starting with
ssf.was registered throughWithScope. The prefix is reserved.
Without a cookie key ring the OP has nothing to seal the header with, which is why the option cannot be enabled without one.
The in-memory, SQL (SQLite, MySQL, PostgreSQL), Redis, DynamoDB and composite adapters all implement the extension. The Redis adapter covers only some substores on its own, so use it through the composite adapter. A composite store that has no route for the SSF substores falls back to the backend serving clients. Both substores must live on one backend, because a stream change moves that stream's queued events in the same operation. A store of your own needs the two substores (SSFStreamStore and SSFEventStore); the contract suite in op/store/contract has a RunSSF runner for it. The SQL adapter creates the two tables with the rest of its schema, and ValidateSchema requires them whether or not you enable the feature.
Endpoints
| Endpoint | Default path (under the mount prefix) | Endpoints field |
|---|---|---|
| Stream configuration | /ssf/streams | SSFConfiguration |
| Stream status | /ssf/status | SSFStatus |
| Verification | /ssf/verify | SSFVerify |
| Poll | /ssf/poll/{stream_id} | SSFPoll |
The transmitter metadata is served at /.well-known/ssf-configuration (with the issuer's path appended when the issuer has one), outside the mount prefix like the protected resource metadata. It reports spec_version 1_0, the push and poll delivery methods, and default_subjects: "ALL". OIDC discovery is unchanged.
Registering a receiver
A receiver is a client you register in your client store, the same way as any other. Two things make it a receiver:
- It is allowed the
client_credentialsgrant. - Its
Scopesincludessf.read,ssf.manageor both.
The OP registers the two scopes itself (not public). Which clients may request them is decided only by client.Scopes.
| Scope | Allows |
|---|---|
ssf.read | reading its own stream and status, polling |
ssf.manage | everything above, plus creating, updating, replacing and deleting the stream, setting the status and requesting verification |
The receiver gets an access token from the token endpoint with the client_credentials grant and calls the SSF endpoints with it in the Authorization header. Only a token the receiver obtained for itself is accepted: a token issued on behalf of a user is refused even if it carries an ssf. scope. JWT and opaque access tokens are both accepted, and DPoP or mTLS binding is checked by the same rules as at /userinfo. A token requested with a resource is refused, because its audience is no longer the issuer. A missing scope is answered with a standard RFC 6750 challenge, and a token from a different client never sees someone else's stream (the answer is 404).
Stream lifecycle
A receiver has at most one stream. A second create is answered with 409.
- Create (
POST /ssf/streams): the receiver sendsdelivery,events_requestedand optionallydescription. With nodeliverythe stream polls. The stream's audience is the receiver'sclient_id.events_deliveredis the intersection of what the receiver asked for and what the OP supports. Right after creation the OP sends a verification event to the stream. - Read (
GET), update (PATCH), replace (PUT), delete (DELETE):PATCHchanges only the members sent, and keeps theauthorization_headerunless a new one is given. APATCHthat moves a push stream to a differentendpoint_urlmust send the header again, or it is answered with 400, so a stored credential is never sent to a host it was not given for.PUTdrops any member that is left out, including the header. Deleting a stream also deletes its queued events. A read never returns theauthorization_header. - Status (
/ssf/status):enabled,pausedordisabled.pausedholds ordinary events in the queue and sends them once the stream is enabled again.disableddrops the ordinary events already queued and ignores new ones.- Every status change queues a
stream-updatedevent, and a verification request queues a verification event. Both are delivered whatever the status is, so a receiver that disabled its stream is still told about it.
- Verification (
POST /ssf/verify): the receiver asks the OP to send a verification event carrying itsstate. At most one verification is pending per stream; a new request replaces the older one.
Push and poll
| Push (RFC 8935) | Poll (RFC 8936) | |
|---|---|---|
| Who starts it | the OP posts to the receiver's endpoint_url | the receiver posts to /ssf/poll/{stream_id} |
| Acknowledgement | 202 from the receiver | ack in the next poll request |
| Failure | retried on a schedule | stays queued until acknowledged |
Push. A background worker, started by op.New and stopped by Provider.Shutdown, claims due SETs every 5 seconds and keeps claiming until the backlog is drained. Each claim takes up to 32 SETs, at most 4 from any one stream. A stream's SETs are pushed one at a time, and up to 8 streams are served in parallel. When a receiver does not take a SET, the rest of that stream's share waits for the next round without counting as a retry, so a slow or stalled receiver does not delay the others. The cost of a claim depends on the batch size and the number of streams with queued push events, not on how far any one stream is behind. A 202 completes the delivery. A 400 drops the SET at once (ssf.push_rejected). A 5xx, a 429 or a network failure is retried after about 1 minute, 5 minutes, 25 minutes and 2 hours (each with up to 10% jitter); after the fourth retry the SET is dropped and ssf.push_abandoned is emitted. A SET that has not been delivered after 7 days is removed from the queue. The receiver's endpoint_url must be an https URL. Redirects are not followed, and private, loopback and cloud-metadata addresses are refused when the connection is made.
Poll. The receiver posts {} and gets the pending SETs; it lists the jti values it has processed under ack in its next request, and those never come back. Unacknowledged SETs are returned again. maxEvents defaults to 32 and is capped at 256; maxEvents: 0 only acknowledges. When nothing is pending and returnImmediately is false or absent, the OP waits up to 10 seconds for something to arrive before answering (long polling). A SET the receiver cannot process can be reported under setErrs, which drops it and emits ssf.poll_set_error.
Signing. A SET is signed when it is sent, not when it is queued, using the active signing key of the OP. A queued event therefore survives a key rotation, and the receiver verifies it against the JWKS the OP publishes now. The jti of a retried SET does not change. Receivers that follow the CAEP Interoperability Profile expect RS256 (section 2.6); configure the OP with op.WithSigningAlgorithm(op.AlgRS256) for them.
Private networks and development
| Option | Effect |
|---|---|
op.WithAllowPrivateNetwork(op.OutboundSharedSignals) | allows RFC 1918 and IPv6 ULA push endpoints. Cloud metadata addresses stay refused |
op.WithAllowInsecureOutboundForDev(op.OutboundSharedSignals) | allows http and loopback push endpoints. op.New refuses it unless the issuer itself is a development host (loopback, localhost, or a .localhost, .test, .invalid, .example name) |
Use both only for local testing against a receiver on the same machine.
Which events a receiver gets
An event about a user is queued for a receiver only if that user has a grant for the receiver's client and the receiver's stream asks for that event type. That is what default_subjects: "ALL" means here: all subjects the receiver's client has a grant for, not every user in your system. The sub_id in the SET is an iss_sub identifier holding the subject as that client sees it, so a pairwise client gets its pairwise value.
The OP produces session-revoked itself:
- RP-Initiated Logout sends one for every session it ends. A user with several sessions therefore causes several
session-revokedevents for one receiver; the OP deliberately does not put a session ID in the SET. Provider.RevokeSubjectsends one before it removes the grants.
Everything else is reported by your application, which is the only place that knows about it:
err := provider.EmitSecurityEvent(ctx, op.CredentialChange{
Subject: userID,
CredentialType: op.CredentialTypePassword,
ChangeType: op.CredentialChangeUpdate,
})The event types are the CAEP CredentialChange and SessionRevoked, and the RISC AccountDisabled, AccountEnabled, AccountPurged and CredentialCompromise. The RISC types are experimental. CAEP events always carry a reason_admin; if you leave it empty the OP fills in a default text.
Call EmitSecurityEvent before the change
Receivers are found through the user's grants. If you call EmitSecurityEvent after RevokeSubject or after deleting the user, the subject has no grants any more, and the event reaches nobody. Emit first, then revoke or delete.
EmitSecurityEvent returns nil and does nothing when WithSharedSignals is not set, so a missing option is not visible at the call site. An empty Subject or an unknown enumeration value is an error either way.
Ordering
There is no ordering guarantee within a stream. Delivery is concurrent and retries reorder events, so a receiver sorts by event_timestamp in the SET.
Audit events
The transmitter emits ten ssf.* events, listed in the audit event catalog. They are audit only and do not update a metric. Watch ssf.push_abandoned and ssf.enqueue_failed in particular: the first means a receiver missed an event, the second means an event never reached the queue, and the logout or revocation that raised it was not failed.
Multiple instances
Any number of OP instances can run the push worker against the same store. The store hands each due event to one worker at a time, so a SET is not pushed twice by different instances. Changes to one stream arriving at several instances at once also leave its queue consistent: every queued event ends up matching the stream's final status and delivery method. See multi-instance deployment for the shared-store requirements in general.