Client state is untrusted. That constraint changes the transport decision for auction notifications: authorize the subscription on the server, then authorize every emitted event against a small, versioned data contract.
Short answer: use an authenticated server-to-client stream for bidder notifications, keep bid submission on a separate request path, and make delivery contingent on current auction membership rather than on a channel name supplied by the app.
A fast connection does not make an authorized connection. The difficult case is not opening the stream; it is proving that account acct_42 may receive auction.au_901.outbid now, after a role change, logout, lot withdrawal, or auction close. Treating the connection handshake as permanent authorization leaves a long-lived decision attached to state that can change underneath it.
Consider a bounded incident exercise, not a claimed production anecdote: a bidder signs in on a shared tablet, subscribes to an auction, and later loses access while the connection remains open. The invariant exposed by that exercise is blunt — possession of a connection is never proof of continuing entitlement.
How should realtime authorization checks shape auction bidder notification data contracts?
Start with the smallest event that lets the client render an alert without learning private bid details. A notification contract needs an event identity, a schema version, an auction scope, an intended subject, a server timestamp, and a public state transition. It should not carry another bidder's identity, an authorization decision copied from the browser, or a raw internal record.
The subject field is routing evidence, not authority. The delivery service still compares it with the authenticated principal and asks the policy layer whether that principal can currently observe the auction. This double condition catches two different mistakes: publishing an event into the wrong logical audience and retaining a subscription after access changes. Don't collapse them into one opaque allowed flag.
package notifications
import (
"encoding/json"
"errors"
"time"
)
type BidderNoticeV1 struct {
EventID string `json:"event_id"`
Version int `json:"version"`
Kind string `json:"kind"`
AuctionID string `json:"auction_id"`
SubjectID string `json:"subject_id"`
State string `json:"state"`
CreatedAt time.Time `json:"created_at"`
}
func DecodeNotice(data []byte) (BidderNoticeV1, error) {
var n BidderNoticeV1
if err := json.Unmarshal(data, &n); err != nil {
return n, err
}
if n.Version != 1 || n.EventID == "" || n.AuctionID == "" || n.SubjectID == "" {
return n, errors.New("invalid bidder notification contract")
}
switch n.Kind {
case "auction.outbid", "auction.closed", "auction.withdrawn":
return n, nil
default:
return n, errors.New("unsupported notification kind")
}
}
Version the envelope explicitly. Additive fields can then be ignored by an older client, while a semantic change gets a new version and a deliberate compatibility window. Reject unknown event kinds at the boundary because silently rendering an unfamiliar state turns a contract mismatch into user-visible ambiguity. The exact compatibility duration depends on mobile release cadence and cannot be inferred from the transport; I'm not sure there is a universal number, so measure the oldest active client version and set the window from that evidence.
Keep money out of this notification payload. The authoritative current price belongs in a separately authorized read response, and bid placement belongs in a separately authenticated command. A notice may prompt a refresh, but it must not become the ledger.
Put authorization on both sides of the queue
The preventative path has two gates. Gate one admits a principal to an auction-scoped subscription. Gate two runs immediately before delivery. Between them, an internal queue may hold an event, but it must not hold a durable promise that the event will be disclosed.
package notifications
import (
"context"
"errors"
)
type Principal struct {
SubjectID string
SessionID string
}
type Policy interface {
CanObserveAuction(ctx context.Context, subjectID, auctionID string) (bool, error)
}
type Sender interface {
Send(ctx context.Context, sessionID string, notice BidderNoticeV1) error
}
func Deliver(ctx context.Context, p Principal, n BidderNoticeV1, policy Policy, sender Sender) error {
if p.SubjectID == "" || p.SessionID == "" {
return errors.New("unauthenticated session")
}
if n.SubjectID != p.SubjectID {
return errors.New("notification subject mismatch")
}
allowed, err := policy.CanObserveAuction(ctx, p.SubjectID, n.AuctionID)
if err != nil {
return err
}
if !allowed {
return errors.New("auction access denied")
}
return sender.Send(ctx, p.SessionID, n)
}
Fail closed.
The operational catch lives in capacity planning: a policy check on every event adds work to the delivery path, but caching authorization for the full connection lifetime weakens revocation, so the useful control is a short-lived decision cache whose key includes the subject, auction, action, and policy version, with invalidation on membership changes. Peak load, not average connection count, sets the requirement. For an auction with a burst of state changes, connections × notices per second is the first-order delivery load; cache hit ratio and revocation lag then determine policy load and exposure. Pick a revocation objective, such as “access removal takes effect before the next eligible notice,” before choosing a cache lifetime. If the team cannot observe that objective, the cache setting is guesswork.
A rejected delivery is not a retryable transport event. Record a low-cardinality reason, stop disclosure, and close or narrow the affected subscription. A temporary policy dependency error should also withhold the notification, though it may be retried internally under a bounded deadline. The browser should receive a generic resubscription outcome, never the policy internals.
Choose the transport after defining trust boundaries
Transport comes second because none of the candidates removes the need for server authorization. A server-to-client stream fits notifications because data flows primarily toward the bidder; a bid command should remain separate, auditable, and idempotent. A bidirectional channel can carry both directions, but combining them increases the number of message types sharing one long-lived security boundary. Peer-to-peer data exchange adds a different trust topology and should not be selected merely because the requirement says “realtime.”
| Option | Useful fit | Operational cost | Trust-boundary concern |
|---|---|---|---|
| Server-to-client stream | Notifications and state hints | Reconnect and cursor handling | Server must recheck every disclosure |
| Bidirectional server channel | Interactive two-way sessions | Connection state and message routing | Commands and notices need distinct authorization |
| Peer-to-peer data channel | Direct participant media or data | Signaling and peer lifecycle | A peer must not become an auction authority |
| Timed refresh | Low-frequency, non-urgent state | Repeated reads and stale windows | Each read is independently authorized |
The W3C WebRTC Recommendation defines peer connections and data channels, which makes it relevant when direct peer communication is actually part of the problem. Auction bidder notifications normally originate from server-owned auction state, so direct peer delivery changes who can assert state and broadens the client trust boundary. That is a poor default decision rule, not a claim that peer data channels are defective.
There is a real exception. If the application already has an authenticated bidirectional session for other interactive features, reusing it can reduce connection-management surface area, provided bid commands and notification events retain separate schemas, authorization actions, rate limits, and audit records. Stick with timed refresh when updates are infrequent, a stale interval is acceptable, and operating long-lived connections would consume more on-call capacity than the latency objective justifies. The catch is that no transport rescues an oversized payload or a policy decision delegated to JavaScript.
Test revocation, replay, and overload before launch
Happy-path connection tests are nearly worthless for this design. The release gate should exercise the moments when identity and auction state change while a notification is in flight. Use a fake policy in unit tests, then run integration tests across authentication, policy, queue, and delivery boundaries.
package notifications
import (
"context"
"testing"
"time"
)
type denyingPolicy struct{}
func (denyingPolicy) CanObserveAuction(context.Context, string, string) (bool, error) {
return false, nil
}
type recordingSender struct{ sent int }
func (s *recordingSender) Send(context.Context, string, BidderNoticeV1) error {
s.sent++
return nil
}
func TestDeliverStopsAfterRevocation(t *testing.T) {
sender := &recordingSender{}
notice := BidderNoticeV1{
EventID: "evt_17", Version: 1, Kind: "auction.outbid",
AuctionID: "au_901", SubjectID: "acct_42",
State: "attention_required", CreatedAt: time.Unix(1_800_000_000, 0).UTC(),
}
err := Deliver(context.Background(), Principal{
SubjectID: "acct_42", SessionID: "sess_8",
}, notice, denyingPolicy{}, sender)
if err == nil {
t.Fatal("expected access denial")
}
if sender.sent != 0 {
t.Fatalf("sent %d notifications after revocation", sender.sent)
}
}
Also test a notice addressed to another subject, an unknown schema version, a duplicate event ID, an expired session, auction closure during queue delay, and reconnect with an old cursor. Duplicate delivery should be harmless: the client can retain a bounded set of event IDs for presentation deduplication, while the server keeps the underlying state authoritative. Never infer permission from possession of a cursor.
Observability needs separate signals for admission denial, pre-delivery denial, schema rejection, queue age, delivery latency, active connections, reconnect rate, and dropped or coalesced low-priority notices. Avoid subject IDs and auction IDs as metric labels; their cardinality grows with the business. Put identifiers in access-controlled structured logs and traces, with retention matched to the security review. Define an SLO around eligible-notification delivery latency and a separate revocation objective, because a healthy latency percentile can coexist with an unsafe stale authorization cache.
Backpressure needs an explicit product decision. An outbid notice may be coalesced into “refresh auction state,” while an auction-closed transition should supersede earlier state hints. Bound every per-connection buffer. Once the bound is reached, disconnect and require a fresh authorized snapshot rather than growing memory without limit or delivering an ancient sequence as if it were current. This is also why event ordering should be scoped to one auction and represented with a server-issued sequence or revision, if the authoritative auction model provides one.
The buy-versus-build decision follows from the same controls. A managed transport can own connection fan-out, while the application still owns identity mapping, auction policy, payload minimization, revocation semantics, and audit evidence. A self-hosted component gives more control over deployment and data paths, but transfers capacity tests, upgrades, and incident response to the platform team.
| Decision factor | Managed transport | Self-hosted transport | Evidence to collect |
|---|---|---|---|
| Peak fan-out | Capacity is purchased | Capacity is engineered | Burst test with auction-shaped traffic |
| Revocation | Application integration remains required | Application integration remains required | Measured access-removal lag |
| On-call work | Part of the transport layer is delegated | Team owns the connection fleet | Alerts, runbooks, and staffing review |
| Lock-in | Provider-specific client protocols | Internal operational tooling | Contract and migration test |
| Client trust | Must remain minimal | Must remain minimal | Threat model and negative authorization tests |
Choose managed delivery when connection operations are undifferentiated work and the service can preserve the contract, revocation objective, and audit boundary. Choose self-hosting when regulatory control, network placement, protocol behavior, or existing operational expertise makes that ownership rational. Neither choice is suitable without an overload policy and a tested reconnect path.
Ship the contract before the transport integration: freeze version 1, validate it at publish and delivery boundaries, test revocation in flight, and load-test the policy path at expected burst fan-out. Then choose the least complex transport that meets the latency SLO without asking the client to prove its own access.
Top comments (1)
Your approach to separating the subscription authorization from the event delivery is spot-on, especially in maintaining the integrity of the auction process. By using a versioned contract and clearly defining the event types, you reduce potential ambiguity, which is critical in a fast-paced environment like auctions. One potential enhancement could be to implement a fallback mechanism for clients that fail to decode newer versions, perhaps by logging the error and providing a user-friendly message. If you're looking for help with improving the notification flow or exploring further security measures, I'd be interested in discussing a paid collaboration.