Shared Signals Framework: A Basic Transmitter in Go
An access token is a snapshot. It says what was true when the issuer created it. But security state can change one second later. A user can sign out. An administrator can disable an account. A device can fall out of compliance.
How does another system learn about that change without waiting for the token to expire?
The Shared Signals Framework (SSF) 1.0 gives cooperating systems a standard way to exchange security events. This post explains the small set of ideas behind SSF and builds a runnable transmitter in Go. The transmitter signs a session-revoked event, stores it in SQLite, and keeps offering it to a receiver until the receiver acknowledges it.
What is SSF?
SSF is an OpenID standard for sending security signals between trusted parties. It defines how two roles work together:
- A transmitter creates and sends events.
- A receiver gets those events, verifies them, and decides what to do.
The two parties communicate through an event stream. A stream records which event types the receiver can expect and whether delivery uses HTTP push or HTTP polling.
Each security event travels as a Security Event Token (SET). A SET is simply a signed JWT for a security event. Its events claim says what happened. The SET specification defines the token; SSF defines how it moves from the transmitter to the receiver.
SSF is the pipe, not the complete meaning of every event. Profiles define useful event families on top of it:
- CAEP covers changes such as a revoked session, changed token claims, or changed device compliance.
- RISC covers account-risk events such as an account being disabled or compromised.
A signal is a fact, not a remote command. The receiver still owns the policy decision. A session-revoked event may cause one receiver to end a session immediately and another to ask for stronger authentication.
A practical example
Imagine a company that connects Apple Business to its identity provider. Employees use their work credentials to sign in to Managed Apple Accounts. During setup, Apple Business asks for the identity provider’s SSF configuration URL. Apple uses that URL to find the SSF endpoints and signing keys.
Apple is the receiver in this connection. The identity provider is the transmitter.
Now suppose an employee leaves the company. An administrator disables the employee’s work account. The identity provider creates a signed SET that describes the change and sends it through the event stream. Apple verifies the SET before using it. It checks that the identity provider signed it and that the event was meant for Apple.

If the SET is valid, Apple can end the employee’s Managed Apple Account session or ask the employee to sign in again. Apple decides which action to take. The identity provider only reports what changed.
Apple can react as soon as the identity provider knows about the change. It does not have to wait for an old access token to expire.
Why SSF matters
Without shared signals, a receiver often has two weak choices. It can trust an access token until it expires, even when the issuer already knows the session is unsafe. Or it can keep asking the issuer whether anything changed, which adds delay and load.
SSF adds a third option: send a small, signed event when the state changes.
That helps in four practical ways:
- Faster response. A receiver can react to a revoked session without waiting for token expiry.
- Clear trust. Signed SETs let the receiver verify who issued an event and who should receive it.
- Shared language. CAEP and RISC give different products the same event names and shapes.
- Reliable delivery. Poll and push protocols define acknowledgements, errors, and redelivery behavior.
SSF does not replace OAuth or OpenID Connect. OAuth still protects API access. OpenID Connect still handles sign-in and identity claims. SSF carries later changes that may affect an existing session or access decision.
What we will build
The demo is a small poll-based SSF transmitter. It implements the parts needed to prove one complete delivery:
main.gois the company’s identity provider and SSF transmitter.main_test.goplays Apple as the SSF receiver.
The code uses https://apple.example/ssf as Apple’s audience. The .example address is deliberately fictional; it is not an Apple production endpoint.
- transmitter discovery at
/.well-known/ssf-configuration; - a public Ed25519 key at
/jwks.json; - one preconfigured event stream at
/ssf/stream; - a local-only
/emitendpoint that creates a CAEPsession-revokedevent; - RFC 8936 poll delivery at
/events; - a SQLite table that holds unacknowledged SETs and schedules redelivery; and
- an end-to-end test in which the Apple receiver discovers the IdP, verifies its SET, and acknowledges delivery.
The /emit endpoint is a demo input, not an SSF endpoint. In a real identity system, an account service or risk engine would call the transmitter through an internal interface.
1. Publish 1 / 2
The event becomes durable before the transmitter reports success.
Read the diagram as text
The Go service signs a Security Event Token, keeps it in SQLite, and removes it only after the receiver acknowledges its JWT ID.
1. Publish — The event becomes durable before the transmitter reports success.
- Report the session revocation: event-source → transmitter. A trusted internal caller tells the transmitter which session was revoked.
- Sign and store the SET: transmitter → queue. The transmitter creates an explicitly typed, Ed25519-signed SET and commits it to SQLite.
2. Discover — Discovery tells the receiver where to find stream metadata and signing keys.
- Request transmitter metadata: receiver → transmitter. The receiver starts with the trusted issuer URL and asks for the SSF endpoints.
- Return endpoints and capabilities: transmitter → receiver. The metadata names the issuer, JWKS URI, stream endpoint, and poll delivery support.
- Fetch the signing key: receiver → transmitter. The receiver fetches the public Ed25519 key identified by the SET header's kid value.
- Return the public JWKS: transmitter → receiver. The receiver now has the public key it needs to verify delivered SETs.
3. Poll + ack — A missing acknowledgement leaves the event eligible for redelivery.
- Poll for available SETs: receiver → transmitter. The receiver asks for up to ten events and requests an immediate response.
- Claim events ready for delivery: transmitter → queue. One SQLite statement selects due rows and moves their next delivery time forward.
- Return the signed SET: queue → transmitter. SQLite returns the JWT ID and compact signed token without deleting the row.
- Deliver the SET map: transmitter → receiver. The response maps each jti to its signed SET so the receiver can verify each item.
- Acknowledge the JWT ID: receiver → transmitter. After validation, the receiver returns the jti in its next poll request.
- Remove the acknowledged event: transmitter → queue. The row is deleted only after the receiver confirms the exact jti.
The important boundary is the SQLite commit. The transmitter reports that an event was queued only after the signed SET is durable. Polling does not delete it. Only an acknowledgement containing the event’s jti removes the row.
The SET we will send
The signed payload looks like this before JWT encoding:
{
"iss": "http://localhost:8080",
"aud": "https://apple.example/ssf",
"iat": 1787337600,
"jti": "e77cf6b5-4876-4450-b380-bd8b92cc7287",
"txn": "af59b929-e94a-4d4c-b009-5f91b99981c0",
"sub_id": {
"format": "opaque",
"id": "session-123"
},
"events": {
"https://schemas.openid.net/secevent/caep/event-type/session-revoked": {
"event_timestamp": 1787337600
}
}
}
A few details are easy to miss:
- The JOSE header uses
typ: "secevent+jwt". This stops code from confusing a SET with an ID token or access token. - The top-level
sub_ididentifies the subject of the event. An SSF SET must not use the normal JWTsubclaim for this purpose. - SSF SETs must not contain
exp. Delivery and retention rules decide when an event is still useful. - The
issvalue must match the stream and the trusted discovery issuer. - The receiver must check that
audidentifies it. jtiidentifies this SET. Poll delivery uses that value for acknowledgement and deduplication.txnconnects SETs that came from the same underlying incident.
The code signs this payload with Ed25519 and publishes the public key as a JSON Web Key Set. A production receiver should verify the signature, typ, iss, aud, event type, and subject before it changes local access.
How the receiver and transmitter interact
The receiver starts with the transmitter’s SSF configuration URL. It uses that document to find the event stream and the transmitter’s public signing key. When a security change happens, the transmitter creates and stores a signed SET.
The receiver polls for events, verifies each SET, and decides what to do. It then acknowledges the SET by its jti. Until that acknowledgement arrives, the transmitter keeps the SET so it can deliver it again.
sequenceDiagram
autonumber
participant Receiver as Receiver (Apple)
participant Transmitter as Transmitter (IdP)
Receiver->>Transmitter: GET /.well-known/ssf-configuration
Transmitter-->>Receiver: Return stream and JWKS URLs
Receiver->>Transmitter: GET /jwks.json
Transmitter-->>Receiver: Return Ed25519 public key
Note over Transmitter: Security state changes
Transmitter->>Transmitter: Create and sign the SET
Transmitter->>Transmitter: Store the SET durably
Receiver->>Transmitter: POST /events (poll)
Transmitter-->>Receiver: Return signed SET keyed by jti
Receiver->>Receiver: Verify signature, issuer, and audience
Receiver->>Receiver: Apply local policy
alt Receiver acknowledges the SET
Receiver->>Transmitter: POST /events with ack: [jti]
Transmitter->>Transmitter: Delete the acknowledged SET
else Acknowledgement is missing
Note over Transmitter: Keep the SET for redelivery
endCreate the Go project
The example needs Go 1.24 or newer. It uses the pure-Go modernc.org/sqlite driver, so it does not need a C compiler.
mkdir ssf-transmitter
cd ssf-transmitter
Create go.mod:
module example.com/ssf-transmitter
go 1.24.0
require modernc.org/sqlite v1.39.1
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/sys v0.36.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
Then create main.go. This is the complete transmitter used by the tests in this post:
package main
import (
"context"
"crypto/ed25519"
"crypto/rand"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"log"
"net/http"
"os"
"strings"
"time"
_ "modernc.org/sqlite"
)
const (
caepSessionRevoked = "https://schemas.openid.net/secevent/caep/event-type/session-revoked"
pollDeliveryMethod = "urn:ietf:rfc:8936"
// This reserved example URL labels Apple as the receiver in the demo. It is
// not an Apple production endpoint.
demoAppleAudience = "https://apple.example/ssf"
)
type config struct {
issuer string
audience string
bearerToken string
keyID string
retryAfter time.Duration
longPollTimeout time.Duration
}
// server is the company identity provider in the article's Apple example.
// The identity provider operates the SSF transmitter; Apple is the receiver.
type server struct {
db *sql.DB
privateKey ed25519.PrivateKey
config config
now func() time.Time
}
type pollRequest struct {
Ack []string `json:"ack,omitempty"`
SetErrors map[string]setError `json:"setErrs,omitempty"`
MaxEvents *int `json:"maxEvents,omitempty"`
ReturnImmediately bool `json:"returnImmediately,omitempty"`
}
type setError struct {
Error string `json:"err"`
Description string `json:"description,omitempty"`
}
type pollResponse struct {
Sets map[string]string `json:"sets"`
MoreAvailable bool `json:"moreAvailable,omitempty"`
}
type streamConfig struct {
StreamID string `json:"stream_id"`
Issuer string `json:"iss"`
Audience []string `json:"aud"`
EventsSupported []string `json:"events_supported"`
EventsRequested []string `json:"events_requested"`
EventsDelivered []string `json:"events_delivered"`
Delivery map[string]any `json:"delivery"`
}
func main() {
address := envOr("SSF_ADDR", ":8080")
issuer := envOr("SSF_ISSUER", "http://localhost:8080")
bearerToken := os.Getenv("SSF_BEARER_TOKEN")
if bearerToken == "" {
log.Fatal("SSF_BEARER_TOKEN is required")
}
db, err := openDatabase(envOr("SSF_DATABASE", "ssf.db"))
if err != nil {
log.Fatal(err)
}
defer db.Close()
privateKey, err := loadOrCreatePrivateKey(envOr("SSF_SIGNING_KEY", "ssf-ed25519.key"))
if err != nil {
log.Fatal(err)
}
identityProvider := &server{
db: db,
privateKey: privateKey,
config: config{
issuer: strings.TrimRight(issuer, "/"),
audience: envOr("SSF_AUDIENCE", demoAppleAudience),
bearerToken: bearerToken,
keyID: "demo-key-1",
retryAfter: 30 * time.Second,
longPollTimeout: 15 * time.Second,
},
now: time.Now,
}
httpServer := &http.Server{
Addr: address,
Handler: identityProvider.routes(),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 20 * time.Second,
WriteTimeout: 20 * time.Second,
IdleTimeout: 60 * time.Second,
}
log.Printf("identity-provider SSF transmitter listening on %s", address)
log.Printf("issuer: %s", identityProvider.config.issuer)
log.Fatal(httpServer.ListenAndServe())
}
func (s *server) routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /.well-known/ssf-configuration", s.handleDiscovery)
mux.HandleFunc("GET /jwks.json", s.handleJWKS)
mux.HandleFunc("GET /ssf/stream", s.requireBearer(s.handleStream))
mux.HandleFunc("POST /events", s.requireBearer(s.handlePoll))
// /emit represents an internal IdP account or risk service, not an SSF API.
mux.HandleFunc("POST /emit", s.requireBearer(s.handleEmit))
return mux
}
func openDatabase(path string) (*sql.DB, error) {
dsn := fmt.Sprintf("file:%s?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)", path)
db, err := sql.Open("sqlite", dsn)
if err != nil {
return nil, fmt.Errorf("open sqlite: %w", err)
}
// One connection keeps this small demo's queue operations easy to reason about.
db.SetMaxOpenConns(1)
const schema = `
CREATE TABLE IF NOT EXISTS ssf_events (
jti TEXT PRIMARY KEY,
set_token TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'queued'
CHECK (status IN ('queued', 'failed')),
created_at INTEGER NOT NULL,
deliver_after INTEGER NOT NULL DEFAULT 0,
attempts INTEGER NOT NULL DEFAULT 0,
last_error TEXT
);
CREATE INDEX IF NOT EXISTS ssf_events_ready
ON ssf_events(status, deliver_after, created_at);`
if _, err := db.Exec(schema); err != nil {
db.Close()
return nil, fmt.Errorf("create queue schema: %w", err)
}
return db, nil
}
func (s *server) handleDiscovery(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"spec_version": "1_0",
"issuer": s.config.issuer,
"jwks_uri": s.config.issuer + "/jwks.json",
"delivery_methods_supported": []string{pollDeliveryMethod},
"configuration_endpoint": s.config.issuer + "/ssf/stream",
})
}
func (s *server) handleJWKS(w http.ResponseWriter, _ *http.Request) {
publicKey := s.privateKey.Public().(ed25519.PublicKey)
writeJSON(w, http.StatusOK, map[string]any{
"keys": []map[string]string{{
"kty": "OKP",
"crv": "Ed25519",
"use": "sig",
"alg": "EdDSA",
"kid": s.config.keyID,
"x": base64.RawURLEncoding.EncodeToString(publicKey),
}},
})
}
func (s *server) handleStream(w http.ResponseWriter, r *http.Request) {
stream := streamConfig{
StreamID: "demo-stream",
Issuer: s.config.issuer,
Audience: []string{s.config.audience},
EventsSupported: []string{caepSessionRevoked},
EventsRequested: []string{caepSessionRevoked},
EventsDelivered: []string{caepSessionRevoked},
Delivery: map[string]any{
"method": pollDeliveryMethod,
"endpoint_url": s.config.issuer + "/events",
},
}
streamID := r.URL.Query().Get("stream_id")
if streamID == "" {
writeJSON(w, http.StatusOK, []streamConfig{stream})
return
}
if streamID != stream.StreamID {
http.Error(w, "stream not found", http.StatusNotFound)
return
}
writeJSON(w, http.StatusOK, stream)
}
func (s *server) handleEmit(w http.ResponseWriter, r *http.Request) {
var input struct {
SubjectID string `json:"subject_id"`
}
if err := decodeJSON(r.Body, &input); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if strings.TrimSpace(input.SubjectID) == "" {
http.Error(w, "subject_id is required", http.StatusBadRequest)
return
}
jti, token, err := s.makeSessionRevokedSET(input.SubjectID)
if err != nil {
http.Error(w, "create SET", http.StatusInternalServerError)
return
}
if _, err := s.db.Exec(
`INSERT INTO ssf_events (jti, set_token, created_at) VALUES (?, ?, ?)`,
jti, token, s.now().Unix(),
); err != nil {
http.Error(w, "queue SET", http.StatusInternalServerError)
return
}
writeJSON(w, http.StatusCreated, map[string]any{"jti": jti, "queued": true})
}
func (s *server) handlePoll(w http.ResponseWriter, r *http.Request) {
var request pollRequest
if err := decodeJSON(r.Body, &request); err != nil {
http.Error(w, "invalid JSON: "+err.Error(), http.StatusBadRequest)
return
}
if err := validatePollRequest(request); err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
response, err := s.poll(r.Context(), request)
if err != nil {
http.Error(w, "poll queue", http.StatusInternalServerError)
return
}
w.Header().Set("Cache-Control", "no-store")
writeJSON(w, http.StatusOK, response)
}
func (s *server) poll(ctx context.Context, request pollRequest) (pollResponse, error) {
response, err := s.applyFeedbackAndClaim(ctx, request)
if err != nil || len(response.Sets) > 0 || request.ReturnImmediately || maxEvents(request) == 0 {
return response, err
}
deadline := time.NewTimer(s.config.longPollTimeout)
defer deadline.Stop()
ticker := time.NewTicker(250 * time.Millisecond)
defer ticker.Stop()
request.Ack = nil
request.SetErrors = nil
for {
select {
case <-ctx.Done():
return pollResponse{}, ctx.Err()
case <-deadline.C:
return pollResponse{Sets: map[string]string{}}, nil
case <-ticker.C:
response, err = s.applyFeedbackAndClaim(ctx, request)
if err != nil || len(response.Sets) > 0 {
return response, err
}
}
}
}
func (s *server) applyFeedbackAndClaim(ctx context.Context, request pollRequest) (pollResponse, error) {
tx, err := s.db.BeginTx(ctx, nil)
if err != nil {
return pollResponse{}, err
}
defer tx.Rollback()
for _, jti := range request.Ack {
if _, err := tx.ExecContext(ctx, `DELETE FROM ssf_events WHERE jti = ?`, jti); err != nil {
return pollResponse{}, err
}
}
for jti, setErr := range request.SetErrors {
message := strings.TrimSpace(setErr.Error + ": " + setErr.Description)
if _, err := tx.ExecContext(ctx,
`UPDATE ssf_events SET status = 'failed', last_error = ? WHERE jti = ?`,
message, jti,
); err != nil {
return pollResponse{}, err
}
}
sets := map[string]string{}
limit := maxEvents(request)
if limit > 0 {
now := s.now()
rows, err := tx.QueryContext(ctx, `
WITH ready AS (
SELECT jti
FROM ssf_events
WHERE status = 'queued' AND deliver_after <= ?
ORDER BY created_at, jti
LIMIT ?
)
UPDATE ssf_events
SET deliver_after = ?, attempts = attempts + 1
WHERE jti IN (SELECT jti FROM ready)
RETURNING jti, set_token`, now.Unix(), limit, now.Add(s.config.retryAfter).Unix())
if err != nil {
return pollResponse{}, err
}
for rows.Next() {
var jti, token string
if err := rows.Scan(&jti, &token); err != nil {
rows.Close()
return pollResponse{}, err
}
sets[jti] = token
}
if err := rows.Close(); err != nil {
return pollResponse{}, err
}
}
var readyCount int
if err := tx.QueryRowContext(ctx,
`SELECT COUNT(*) FROM ssf_events WHERE status = 'queued' AND deliver_after <= ?`,
s.now().Unix(),
).Scan(&readyCount); err != nil {
return pollResponse{}, err
}
if err := tx.Commit(); err != nil {
return pollResponse{}, err
}
return pollResponse{Sets: sets, MoreAvailable: readyCount > 0}, nil
}
func maxEvents(request pollRequest) int {
if request.MaxEvents == nil {
return 100 // A transmitter-side batch cap keeps responses bounded.
}
return *request.MaxEvents
}
func validatePollRequest(request pollRequest) error {
if request.MaxEvents != nil && (*request.MaxEvents < 0 || *request.MaxEvents > 100) {
return errors.New("maxEvents must be between 0 and 100")
}
seen := make(map[string]struct{}, len(request.Ack))
for _, jti := range request.Ack {
if jti == "" {
return errors.New("ack values cannot be empty")
}
seen[jti] = struct{}{}
}
for jti, setErr := range request.SetErrors {
if jti == "" || setErr.Error == "" {
return errors.New("setErrs requires a jti and err value")
}
if _, exists := seen[jti]; exists {
return errors.New("the same jti cannot appear in ack and setErrs")
}
}
return nil
}
func (s *server) makeSessionRevokedSET(subjectID string) (string, string, error) {
jti, err := randomID()
if err != nil {
return "", "", err
}
txn, err := randomID()
if err != nil {
return "", "", err
}
now := s.now().Unix()
header := map[string]any{
"alg": "EdDSA",
"kid": s.config.keyID,
"typ": "secevent+jwt",
}
claims := map[string]any{
"iss": s.config.issuer,
"aud": s.config.audience,
"iat": now,
"jti": jti,
"txn": txn,
"sub_id": map[string]string{
"format": "opaque",
"id": subjectID,
},
"events": map[string]any{
caepSessionRevoked: map[string]any{"event_timestamp": now},
},
}
headerJSON, err := json.Marshal(header)
if err != nil {
return "", "", err
}
claimsJSON, err := json.Marshal(claims)
if err != nil {
return "", "", err
}
encodedHeader := base64.RawURLEncoding.EncodeToString(headerJSON)
encodedClaims := base64.RawURLEncoding.EncodeToString(claimsJSON)
signingInput := encodedHeader + "." + encodedClaims
signature := ed25519.Sign(s.privateKey, []byte(signingInput))
token := signingInput + "." + base64.RawURLEncoding.EncodeToString(signature)
return jti, token, nil
}
func (s *server) requireBearer(next http.HandlerFunc) http.HandlerFunc {
expected := []byte("Bearer " + s.config.bearerToken)
return func(w http.ResponseWriter, r *http.Request) {
provided := []byte(r.Header.Get("Authorization"))
if len(provided) != len(expected) || subtle.ConstantTimeCompare(provided, expected) != 1 {
w.Header().Set("WWW-Authenticate", "Bearer")
http.Error(w, "unauthorized", http.StatusUnauthorized)
return
}
next(w, r)
}
}
func loadOrCreatePrivateKey(path string) (ed25519.PrivateKey, error) {
key, err := os.ReadFile(path)
if err == nil {
if len(key) != ed25519.PrivateKeySize {
return nil, fmt.Errorf("%s does not contain an Ed25519 private key", path)
}
return ed25519.PrivateKey(key), nil
}
if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("read signing key: %w", err)
}
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, fmt.Errorf("generate signing key: %w", err)
}
if err := os.WriteFile(path, privateKey, 0o600); err != nil {
return nil, fmt.Errorf("save signing key: %w", err)
}
return privateKey, nil
}
func randomID() (string, error) {
var value [16]byte
if _, err := rand.Read(value[:]); err != nil {
return "", err
}
value[6] = (value[6] & 0x0f) | 0x40
value[8] = (value[8] & 0x3f) | 0x80
return fmt.Sprintf("%08x-%04x-%04x-%04x-%012x",
value[0:4], value[4:6], value[6:8], value[8:10], value[10:16]), nil
}
func decodeJSON(body io.Reader, destination any) error {
decoder := json.NewDecoder(io.LimitReader(body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(destination); err != nil {
return err
}
if err := decoder.Decode(&struct{}{}); !errors.Is(err, io.EOF) {
return errors.New("request body must contain one JSON object")
}
return nil
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
if err := json.NewEncoder(w).Encode(value); err != nil {
log.Printf("encode JSON response: %v", err)
}
}
func envOr(name, fallback string) string {
if value := os.Getenv(name); value != "" {
return value
}
return fallback
}
The queue has a small state model:
- A new row starts as
queuedand can be delivered now. - A poll atomically moves
deliver_afterinto the future and returns the SET. - If no acknowledgement arrives, the same row becomes available again after 30 seconds.
- An
ackdeletes the row. - A
setErrsentry marks the row asfailedso the demo keeps it for inspection instead of sending the same invalid token forever.
SQLite is not part of SSF. It is only the transmitter’s internal durable queue. PostgreSQL, a cloud queue, or another durable store can serve the same role as long as claiming and acknowledging events stay safe under concurrency.
Run the transmitter
First download the dependency and start the server:
go mod tidy
export SSF_BEARER_TOKEN=dev-token
go run .
The first run creates two local files:
ssf.dbholds the delivery queue.ssf-ed25519.keyholds the private signing key with file mode0600.
The discovery document is public:
curl -s http://localhost:8080/.well-known/ssf-configuration | jq
It points the receiver to the JWKS and stream configuration endpoints.
Queue and poll one event
In another terminal, create a session-revoked event:
curl -s \
-H 'Authorization: Bearer dev-token' \
-H 'Content-Type: application/json' \
-d '{"subject_id":"session-123"}' \
http://localhost:8080/emit | jq
The response contains the new jti:
{
"jti": "e77cf6b5-4876-4450-b380-bd8b92cc7287",
"queued": true
}
Now poll the stream and save the response:
POLL_RESPONSE=$(curl -s \
-H 'Authorization: Bearer dev-token' \
-H 'Content-Type: application/json' \
-d '{"maxEvents":10,"returnImmediately":true}' \
http://localhost:8080/events)
echo "$POLL_RESPONSE" | jq
RFC 8936 returns a sets object. Each key is a jti; each value is the compact signed SET:
{
"sets": {
"e77cf6b5-4876-4450-b380-bd8b92cc7287": "eyJhbGciOiJFZERTQSIsImtpZCI6ImRlbW8ta2V5LTEiLCJ0eXAiOiJzZWNldmVudCtqd3QifQ..."
}
}
Poll again before acknowledging and the response is empty until the 30-second retry window passes. After that window, the same jti and SET are delivered again. This is at-least-once delivery, so a receiver must deduplicate events by jti.
Once the receiver has verified and retained the SET, acknowledge it:
JTI=$(echo "$POLL_RESPONSE" | jq -r '.sets | keys[0]')
curl -s \
-H 'Authorization: Bearer dev-token' \
-H 'Content-Type: application/json' \
-d "{\"ack\":[\"$JTI\"],\"maxEvents\":0,\"returnImmediately\":true}" \
http://localhost:8080/events | jq
maxEvents: 0 makes this an acknowledge-only request. The transmitter deletes the matching SQLite row and returns an empty set:
{
"sets": {}
}
You can inspect the queue directly if the sqlite3 CLI is installed:
sqlite3 ssf.db \
'SELECT jti, status, attempts, last_error FROM ssf_events;'
Before the acknowledgement, the query shows one row. After it, the query returns no rows.
Prove the complete flow with a test
Create main_test.go beside main.go:
package main
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"database/sql"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
const demoBearerToken = "test-token"
// appleReceiver is the Apple side of this public-protocol example. It uses
// transmitter metadata supplied by the IdP; it does not model Apple's private
// implementation or use an Apple production endpoint.
type appleReceiver struct {
pollEndpoint string
bearerToken string
expectedIssuer string
expectedAudience string
keyID string
publicKey ed25519.PublicKey
}
type transmitterMetadata struct {
Issuer string `json:"issuer"`
JWKSURI string `json:"jwks_uri"`
DeliveryMethodsSupported []string `json:"delivery_methods_supported"`
ConfigurationEndpoint string `json:"configuration_endpoint"`
}
type jwksDocument struct {
Keys []struct {
KeyType string `json:"kty"`
Curve string `json:"crv"`
Use string `json:"use"`
Alg string `json:"alg"`
KeyID string `json:"kid"`
X string `json:"x"`
} `json:"keys"`
}
func TestAppleReceiverPollsIdentityProviderUntilAcknowledged(t *testing.T) {
db, err := openDatabase(t.TempDir() + "/ssf.db")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { db.Close() })
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatal(err)
}
now := time.Unix(1_787_337_600, 0)
// IDP SIDE: the company's identity provider creates, signs, queues, and
// serves SETs. In the Apple Business example, this is the SSF transmitter.
identityProvider := &server{
db: db,
privateKey: privateKey,
config: config{
audience: demoAppleAudience,
bearerToken: demoBearerToken,
keyID: "test-key",
retryAfter: 30 * time.Second,
longPollTimeout: time.Second,
},
now: func() time.Time { return now },
}
idpHTTPServer := httptest.NewServer(identityProvider.routes())
t.Cleanup(idpHTTPServer.Close)
identityProvider.config.issuer = idpHTTPServer.URL
// APPLE SIDE: Apple starts with the IdP's SSF configuration URL. It uses
// discovery to learn the stream endpoint and download the IdP's public key.
apple := connectAppleReceiver(
t,
idpHTTPServer.URL+"/.well-known/ssf-configuration",
demoBearerToken,
)
// An IdP account service reports that the employee session was revoked.
// /emit is an internal demo input; Apple never calls it.
emit := postJSON(
t,
idpHTTPServer.URL+"/emit",
demoBearerToken,
`{"subject_id":"session-123"}`,
)
if emit.StatusCode != http.StatusCreated {
t.Fatalf("emit status = %d", emit.StatusCode)
}
var emitted struct {
JTI string `json:"jti"`
}
decodeResponse(t, emit, &emitted)
// Apple polls the IdP, then verifies the SET before applying local policy.
first := apple.poll(t, `{"maxEvents":10,"returnImmediately":true}`)
token := first.Sets[emitted.JTI]
if token == "" {
t.Fatalf("first poll did not include %s", emitted.JTI)
}
apple.verifySET(t, token, emitted.JTI, "session-123")
// The IdP keeps the SET until Apple acknowledges it, so a missed response
// does not lose the security event.
assertQueueSize(t, db, 1)
now = now.Add(31 * time.Second)
second := apple.poll(t, `{"maxEvents":10,"returnImmediately":true}`)
if second.Sets[emitted.JTI] != token {
t.Fatal("unacknowledged SET was not redelivered")
}
ackBody := `{"ack":["` + emitted.JTI + `"],"maxEvents":0,"returnImmediately":true}`
ack := apple.poll(t, ackBody)
if len(ack.Sets) != 0 {
t.Fatalf("ack-only response contained %d SETs", len(ack.Sets))
}
assertQueueSize(t, db, 0)
empty := apple.poll(t, `{"maxEvents":10,"returnImmediately":true}`)
if len(empty.Sets) != 0 {
t.Fatalf("poll after ack contained %d SETs", len(empty.Sets))
}
}
func connectAppleReceiver(t *testing.T, configurationURL, bearerToken string) appleReceiver {
t.Helper()
metadataResponse := get(t, configurationURL, "")
if metadataResponse.StatusCode != http.StatusOK {
t.Fatalf("discovery status = %d", metadataResponse.StatusCode)
}
var metadata transmitterMetadata
decodeResponse(t, metadataResponse, &metadata)
if metadata.Issuer == "" || metadata.JWKSURI == "" || metadata.ConfigurationEndpoint == "" {
t.Fatalf("incomplete transmitter metadata: %#v", metadata)
}
if len(metadata.DeliveryMethodsSupported) != 1 || metadata.DeliveryMethodsSupported[0] != pollDeliveryMethod {
t.Fatalf("unexpected delivery methods: %#v", metadata.DeliveryMethodsSupported)
}
jwksResponse := get(t, metadata.JWKSURI, "")
if jwksResponse.StatusCode != http.StatusOK {
t.Fatalf("JWKS status = %d", jwksResponse.StatusCode)
}
var jwks jwksDocument
decodeResponse(t, jwksResponse, &jwks)
if len(jwks.Keys) != 1 {
t.Fatalf("JWKS contains %d keys", len(jwks.Keys))
}
key := jwks.Keys[0]
if key.KeyType != "OKP" || key.Curve != "Ed25519" || key.Use != "sig" || key.Alg != "EdDSA" {
t.Fatalf("unexpected JWK: %#v", key)
}
publicKey, err := base64.RawURLEncoding.DecodeString(key.X)
if err != nil {
t.Fatal(err)
}
if len(publicKey) != ed25519.PublicKeySize {
t.Fatalf("public key has %d bytes", len(publicKey))
}
streamResponse := get(t, metadata.ConfigurationEndpoint, bearerToken)
if streamResponse.StatusCode != http.StatusOK {
t.Fatalf("stream configuration status = %d", streamResponse.StatusCode)
}
var streams []streamConfig
decodeResponse(t, streamResponse, &streams)
if len(streams) != 1 {
t.Fatalf("stream configuration contains %d streams", len(streams))
}
stream := streams[0]
if stream.Issuer != metadata.Issuer {
t.Fatalf("stream issuer = %q, want %q", stream.Issuer, metadata.Issuer)
}
if len(stream.Audience) != 1 || stream.Audience[0] != demoAppleAudience {
t.Fatalf("stream audience = %#v", stream.Audience)
}
if stream.Delivery["method"] != pollDeliveryMethod {
t.Fatalf("delivery method = %v", stream.Delivery["method"])
}
pollEndpoint, ok := stream.Delivery["endpoint_url"].(string)
if !ok || pollEndpoint == "" {
t.Fatalf("missing poll endpoint: %#v", stream.Delivery)
}
return appleReceiver{
pollEndpoint: pollEndpoint,
bearerToken: bearerToken,
expectedIssuer: metadata.Issuer,
expectedAudience: demoAppleAudience,
keyID: key.KeyID,
publicKey: ed25519.PublicKey(publicKey),
}
}
func (a appleReceiver) poll(t *testing.T, body string) pollResponse {
t.Helper()
response := postJSON(t, a.pollEndpoint, a.bearerToken, body)
if response.StatusCode != http.StatusOK {
t.Fatalf("poll status = %d", response.StatusCode)
}
var result pollResponse
decodeResponse(t, response, &result)
return result
}
func (a appleReceiver) verifySET(t *testing.T, token, expectedJTI, expectedSubjectID string) {
t.Helper()
parts := strings.Split(token, ".")
if len(parts) != 3 {
t.Fatalf("SET has %d parts", len(parts))
}
signature, err := base64.RawURLEncoding.DecodeString(parts[2])
if err != nil {
t.Fatal(err)
}
if !ed25519.Verify(a.publicKey, []byte(parts[0]+"."+parts[1]), signature) {
t.Fatal("SET signature is invalid")
}
headerJSON, err := base64.RawURLEncoding.DecodeString(parts[0])
if err != nil {
t.Fatal(err)
}
var header map[string]any
if err := json.Unmarshal(headerJSON, &header); err != nil {
t.Fatal(err)
}
if header["typ"] != "secevent+jwt" || header["alg"] != "EdDSA" || header["kid"] != a.keyID {
t.Fatalf("unexpected JOSE header: %#v", header)
}
claimsJSON, err := base64.RawURLEncoding.DecodeString(parts[1])
if err != nil {
t.Fatal(err)
}
decoder := json.NewDecoder(bytes.NewReader(claimsJSON))
decoder.UseNumber()
var claims map[string]any
if err := decoder.Decode(&claims); err != nil {
t.Fatal(err)
}
if claims["iss"] != a.expectedIssuer {
t.Fatalf("iss = %v, want %q", claims["iss"], a.expectedIssuer)
}
if claims["aud"] != a.expectedAudience {
t.Fatalf("aud = %v, want %q", claims["aud"], a.expectedAudience)
}
if claims["jti"] != expectedJTI {
t.Fatalf("jti = %v", claims["jti"])
}
if _, exists := claims["sub"]; exists {
t.Fatal("SSF SET must not contain sub")
}
if _, exists := claims["exp"]; exists {
t.Fatal("SSF SET must not contain exp")
}
subject, ok := claims["sub_id"].(map[string]any)
if !ok || subject["format"] != "opaque" || subject["id"] != expectedSubjectID {
t.Fatalf("unexpected subject: %#v", claims["sub_id"])
}
events, ok := claims["events"].(map[string]any)
if !ok || events[caepSessionRevoked] == nil {
t.Fatalf("missing CAEP session-revoked event: %#v", claims["events"])
}
}
func get(t *testing.T, url, bearerToken string) *http.Response {
t.Helper()
request, err := http.NewRequest(http.MethodGet, url, nil)
if err != nil {
t.Fatal(err)
}
if bearerToken != "" {
request.Header.Set("Authorization", "Bearer "+bearerToken)
}
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
return response
}
func postJSON(t *testing.T, url, bearerToken, body string) *http.Response {
t.Helper()
request, err := http.NewRequest(http.MethodPost, url, strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
request.Header.Set("Authorization", "Bearer "+bearerToken)
request.Header.Set("Content-Type", "application/json")
response, err := http.DefaultClient.Do(request)
if err != nil {
t.Fatal(err)
}
return response
}
func decodeResponse(t *testing.T, response *http.Response, destination any) {
t.Helper()
defer response.Body.Close()
if err := json.NewDecoder(response.Body).Decode(destination); err != nil {
t.Fatal(err)
}
}
func assertQueueSize(t *testing.T, db interface {
QueryRow(query string, args ...any) *sql.Row
}, expected int) {
t.Helper()
var count int
if err := db.QueryRow(`SELECT COUNT(*) FROM ssf_events`).Scan(&count); err != nil {
t.Fatal(err)
}
if count != expected {
t.Fatalf("queue size = %d, want %d", count, expected)
}
}
Run it:
go test ./...
The test proves more than a handler returning 200. It checks the boundary between both roles:
- Apple discovers the IdP’s stream endpoint and downloads its public signing key;
- the IdP commits a SET to SQLite before delivery;
- the IdP redelivers the same token when Apple has not acknowledged it;
- Apple verifies the signature, key ID,
typ,iss,aud, event type, and subject; and - the IdP removes the row after Apple acknowledges its
jti.
What to change before production
This demo is intentionally small. It is useful for learning and local integration tests, but it is not a production SSF service.
Before using the design in production:
- Serve every endpoint over HTTPS. The
http://localhostissuer exists only for this local demo. - Replace the shared development bearer token with a strong authorization scheme. Depending on the partnership, that may be OAuth client credentials, private-key JWT authentication, or mutual TLS.
- Store signing keys in a key-management system, publish overlapping keys during rotation, and handle JWKS caching safely.
- Implement the full stream management, status, subject, and verification APIs that your interoperability profile requires.
- Validate requested event types and ensure one receiver cannot read another receiver’s stream.
- Encrypt SETs with JWE when TLS does not provide enough confidentiality for the event data.
- Add rate limits, request size limits, audit logs, metrics, retention rules, and dead-letter handling.
- Test duplicate delivery, concurrent pollers, database failures, key rotation, unknown event types, and malformed acknowledgements.
Most importantly, agree on receiver behavior. SSF tells you how to exchange a trusted signal. The two parties still need a clear contract for what that signal means and how quickly the receiver should act.
The main idea
SSF closes the gap between “this token was valid when I issued it” and “the security state has changed now.”
A basic transmitter needs only a few solid pieces: create a correctly shaped SET, sign it, store it before reporting success, deliver it through a standard method, and keep it until the receiver acknowledges the exact jti.
The Go service in this post does that with one process and one SQLite file. The code is small enough to inspect, but the delivery behavior is real: no acknowledgement means redelivery; acknowledgement means the message leaves the queue.