Web Push
Notify users whose app is closed — VAPID keys, the subscription store, encrypted delivery with dead-subscription cleanup, and the browser/service-worker half, without a third-party push service.
Client events reach a user who has the app open. Web Push (RFC 8030, encrypted per RFC 8291, authenticated with VAPID per RFC 8292) reaches one who does not: a browser or installed PWA subscribes once, and the server can notify it later with the app closed — "your deploy failed", "time to feed the starter". It is what an installed PWA on a phone needs to be useful.
Every piece of that is framework-shaped, so Elarion owns it (ADR-0076): the VAPID key pair, the subscription store, the send loop with cleanup, the subscribe endpoints, and the browser and service-worker plumbing. Who receives a notification, when, and what it says stay in the application.
| Package | Role |
|---|---|
Elarion.WebPush | IWebPushSender, WebPushSubscriptionService, VAPID keys, the store seams, in-memory defaults. |
Elarion.WebPush.EntityFrameworkCore | Durable elarion_push_subscriptions and elarion_vapid_keys tables (PostgreSQL or SQLite). |
Elarion.WebPush.AspNetCore | MapElarionWebPush() — HTTP bindings for the three subscription handlers your application declares. |
@swimmesberger/elarion-webpush | Availability detection, permission and subscription helpers, and the service-worker module. |
Wiring
// The DbContext maps the two tables:
[GenerateDbSets]
[GenerateElarionWebPush]
public partial class AppDbContext(DbContextOptions<AppDbContext> options) : DbContext(options) {
protected override void OnModelCreating(ModelBuilder modelBuilder) => ConfigureEntities(modelBuilder);
}
builder.Services.AddElarionSettingsDataProtection(); // protects the VAPID private key at rest (any ISettingValueProtector)
builder.Services.AddElarionWebPushEntityFrameworkCore<AppDbContext>(options =>
options.Subject = "mailto:ops@example.com"); // required: the VAPID contact
app.MapElarionWebPush().RequireAuthorization(); // GET public-key, POST subscribe/unsubscribe under /webpushplus the three handlers below, which MapElarionWebPush() dispatches to.
The EF stores write with INSERT … ON CONFLICT statements, which run on PostgreSQL and SQLite (3.24+).
Their parameters are created from the model's own type mappings, so a context-wide value converter applies to
them as it does to a tracked insert — a SQLite application that stores DateTimeOffset as a sortable integer
keeps doing so. Another provider implements IPushSubscriptionStore and IVapidKeyStore itself.
The subscription store is scoped and works on the caller's own context. Inside a unit of work — a command handler under the transaction decorator — a subscribe, an unsubscribe, or the cleanup of a dead subscription during a send joins that transaction and commits or rolls back with the handler's other writes; outside one (an endpoint, a job) each statement commits on its own. So the subscribe handlers below need no special pipeline, on PostgreSQL or on SQLite, where a second connection would wait for the handler's write lock.
AddElarionWebPush(...) alone registers the same services over in-memory stores — fine for tests and a
single-node demo, but both the subscriptions and the key pair vanish on restart.
The VAPID key pair
Browsers bind every subscription to the server's public key, so the key pair must be stable: rotating it
invalidates every subscription. IVapidKeyProvider resolves it in this order:
- Configuration —
options.PublicKeyandoptions.PrivateKey(base64url; set both or neither). Use this to keep the private key in a secret store. - The store — the
elarion_vapid_keysrow. - Generated on first use and stored. Two nodes racing the first start both insert with
ON CONFLICT DO NOTHINGand read the row back, so they agree on one pair.
With the EF stores the pair is resolved when the host starts, not in the first request that needs it. Once resolved it is cached for the life of the process, so it must be stored for good before it is used — never inside a request's transaction, whose rollback could un-store a key browsers already subscribed against. If the tables do not exist yet at start, that is logged and the pair is resolved on first use instead.
The private key is protected at rest (ADR-0078's seam): the EF store writes it through the registered
ISettingValueProtector — AddElarionSettingsDataProtection() is the shipped implementation over ASP.NET Core
Data Protection, whose key ring you must persist and share between nodes — bound to the pair's public key, with
the protector's scheme in the row's protection column. The store fails closed: without a protector it cannot
be resolved, and a payload that does not decrypt (a lost key ring) throws instead of minting a replacement pair,
which would invalidate every subscription. A row written before protection existed (no protection value) is
accepted once and re-protected in place, as is a payload under a retired Data Protection key. Upgrading adds
the nullable protection column and widens private_key to 1024 characters — add an EF migration.
Keys configured through options.PublicKey/PrivateKey never touch the store, and an application with its own
IVapidKeyStore (a vault, a KMS) registers it after AddElarionWebPushEntityFrameworkCore and needs no protector.
VapidKeys.Generate() mints a pair if you want to pin one from the start.
To move an existing pair into the store (so browsers' current subscriptions keep working), call
IVapidKeyStore.ImportAsync(keys) once, before the first push is sent. The EF store protects the private key with
its own purpose, so you never build the protection format yourself. The result is Imported, Unchanged when the
identical pair is already stored (safe to run on every start), or Refused when a different pair is stored —
replacing it would invalidate every subscription, so it takes an explicit overwrite: true (Replaced). Concurrent
nodes importing different pairs leave exactly one winner. The provider caches the pair per process, so restart other
nodes after an overwrite.
Sending
Resolve IWebPushSender (scoped) wherever the trigger lives — a handler, an integration-event consumer, a
scheduled job:
[ConsumeEvent]
public sealed class NotifyOnFailedDeploy(IWebPushSender push, AppDbContext db) : IHandler<DeployFailed> {
public async ValueTask<Result> HandleAsync(DeployFailed e, CancellationToken ct) {
var operators = await db.Operators.Select(o => o.UserId).ToListAsync(ct);
await push.SendToUsersAsync(operators, new WebPushMessage {
Title = "Deploy failed",
Body = $"{e.Service}@{e.Version} failed its health check.",
Url = $"/deploys/{e.DeployId}",
Tag = $"deploy-{e.DeployId}",
Urgency = WebPushUrgency.High,
}, ct);
return Result.Success();
}
}The sender loads the users' subscriptions, encrypts the {title, body, url, tag} payload for each, and sends
them with bounded concurrency (MaxConcurrentSends, default 8). The returned WebPushResult counts what
happened:
| Push service answer | Outcome | Subscription |
|---|---|---|
| 2xx | Delivered | kept |
| 404 / 410 | Removed | deleted — the browser unsubscribed or the subscription expired |
| malformed keys, disallowed endpoint | Removed | deleted — it can never be delivered to |
| 429, 5xx, timeout, network error, other 4xx | Failed | kept |
A refused message (any non-2xx other than 404/410) is logged as a warning with the push service's own reason
from the response body — Apple answers with {"reason":"…"}, FCM and Mozilla with a sentence — capped at 256
characters and flattened to one line. That reason is the only place a push service says why.
Web Push is best-effort: "delivered" means the push service accepted the message, not that the device showed it, and a failed send is not retried. If a notification must not be lost, send it from a durable trigger (an integration event through the outbox, a job) so the retry is yours.
Message fields map onto the protocol:
Tagreplaces rather than stacks: the browser replaces a displayed notification with the same tag. It is deliberately not sent as the RFC 8030Topicheader — Apple's push service (web.push.apple.com) rejects every request that carries one with400 BadWebPushTopic, whatever its value — so the push service keeps each undelivered message, and a device coming back online receives them in turn, each replacing the last. KeepTimeToLiveshort for messages that go stale.Urgency(VeryLow…High) tells the device how urgently to wake up.Highcosts battery; reserve it.TimeToLiveis how long the push service holds the message for an offline device; it defaults tooptions.DefaultTimeToLive(one day).TimeSpan.Zeromeans "now or never".- The payload must fit one 4 KiB push message (about 3,990 bytes of JSON); a longer one throws. Put the details
behind
Url.
SendToCurrentUserAsync(message) sends to the caller's own devices — the "send a test notification" button.
To address an audience, ask the store rather than its table: IPushSubscriptionStore.ListSubscribedUserIdsAsync()
returns the distinct ids of every user that owns a subscription, ListSubscribedUserIdsAsync(candidates) narrows
a candidate list to those with at least one device, and ListByUsersAsync(userIds) returns the subscriptions
themselves. A custom store implements all three.
Subscribing
Subscribing is an operation of your application, so it runs through your handler pipeline like every
other one: global authorization rules, tenant and realm rules, audit, rate limiting and validation apply to it.
The framework therefore ships the request types and the shared logic, not the handlers. Declare three
[Handler]s that delegate to WebPushSubscriptionService — they appear in the JSON-RPC schema and the
generated TypeScript client like any other:
[Handler("webPush.subscribe")]
public sealed class SubscribeToPush(WebPushSubscriptionService subscriptions) : IHandler<PushSubscriptionRequest> {
public ValueTask<Result> HandleAsync(PushSubscriptionRequest request, CancellationToken ct) =>
subscriptions.SubscribeAsync(request, cancellationToken: ct);
}
[Handler("webPush.unsubscribe")]
public sealed class UnsubscribeFromPush(WebPushSubscriptionService subscriptions) : IHandler<WebPushUnsubscribeRequest> {
public ValueTask<Result> HandleAsync(WebPushUnsubscribeRequest request, CancellationToken ct) =>
subscriptions.UnsubscribeAsync(request.Endpoint, ct);
}
[Handler("webPush.publicKey")]
public sealed class GetPushPublicKey(WebPushSubscriptionService subscriptions)
: IHandler<WebPushPublicKeyRequest, Result<WebPushPublicKeyResponse>> {
public async ValueTask<Result<WebPushPublicKeyResponse>> HandleAsync(WebPushPublicKeyRequest request, CancellationToken ct) =>
new WebPushPublicKeyResponse(await subscriptions.GetPublicKeyAsync(ct));
}Subscriptions belong to the current user (ICurrentUser); the service insists the caller is authenticated, so
a subscription always has an owner, and add [RequirePermission] or any other attribute to the handlers to
restrict who may subscribe.
MapElarionWebPush() is the HTTP binding for exactly those handlers — it resolves the registered
IHandlers for PushSubscriptionRequest, WebPushUnsubscribeRequest and WebPushPublicKeyRequest and calls
them, so the decorators the generator wrapped around them decide every call. It fails at startup, naming the
missing handlers, if one is not registered; there is deliberately no undecorated fallback that could bypass the
pipeline by accident. The wire shapes are the browser package's fixed contract:
| Endpoint | Body | Result |
|---|---|---|
GET /webpush/public-key | — | 200 {"publicKey":"…"} |
POST /webpush/subscribe | subscription.toJSON() | 204; 400 invalid or refused; 401 anonymous; 403 forbidden |
POST /webpush/unsubscribe | {"endpoint":"…"} | 204; 401 anonymous; 403 forbidden |
A handler failure maps to a bodyless status by its error kind (Validation 400, Unauthorized 401, Forbidden
403, …). A malformed body is answered 401/403 by the handler's admission gate when the caller may not call it
at all, and 400 otherwise — an anonymous caller never learns the request shape. The endpoint copies the
request's User-Agent header into PushSubscriptionRequest.UserAgent (a property that is not part of the JSON
contract). Apply endpoint-level conventions (.RequireAuthorization(), a rate-limiter policy) to the returned
group as for any minimal API.
Subscribing is an upsert by endpoint: the same device re-subscribing refreshes its keys and last-seen
time, and re-subscribing under a different account reassigns the row instead of colliding on the unique
endpoint. The reassignment requires the subscription's stored auth secret, which the same browser always
sends. A request for an endpoint another user owns, with other keys, writes nothing and fails with the same
Validation error (400) as an invalid subscription, so a user who learned someone else's endpoint cannot
redirect it to keys of their own. The check is part of the store's single INSERT … ON CONFLICT … DO UPDATE … WHERE statement, not a read before the write, so concurrent subscribes for one endpoint leave exactly one owner.
The secret is compared with plain SQL equality rather than in constant time. That is acceptable because it is
16 random bytes and each guess costs an authenticated request and a database round trip. A custom
IPushSubscriptionStore must apply the same rule atomically and return from UpsertAsync whether it wrote the
row. Unsubscribing only ever deletes the caller's own subscription.
Endpoint allow-list
The server POSTs to whatever endpoint a browser hands it, so an unrestricted endpoint would let any signed-in
user aim server-side requests at an internal address. Subscriptions are therefore accepted only for https
endpoints on the known push services (Google FCM, Mozilla, Apple, Windows) and their subdomains, and the
delivery client does not follow redirects. Add a host to options.AllowedEndpointHosts for another push
service, or set options.AllowAnyEndpointHost only if every caller that can subscribe is trusted.
The deliveries use the named HttpClient WebPushOptions.HttpClientName; configure it
(services.AddHttpClient(WebPushOptions.HttpClientName)…) to add a proxy or resilience handler.
Browser
import { enablePush, fetchWebPushApi, pushAvailability, refreshOnStart } from '@swimmesberger/elarion-webpush'
const api = fetchWebPushApi() // the MapElarionWebPush endpoints
void refreshOnStart(api) // every app start: heals rotated or cleaned-up subscriptions
switch (pushAvailability()) {
case 'available': // offer the switch
toggle.onclick = async () => showOutcome(await enablePush(api)) // 'subscribed' | 'denied' | 'dismissed'
break
case 'install-first': // iOS/iPadOS Safari outside a Home Screen app
hint.textContent = 'Add this app to your Home Screen to turn on notifications.'
break
case 'unsupported': // hide the switch
}pushAvailability()returnsinstall-firston iOS/iPadOS 16.4+ Safari outside a Home Screen install, wherePushManagerdoes not exist yet — say "add to Home Screen first" instead of offering a switch that can never turn on. Before 16.4 it isunsupported.enablePushrequests permission before its firstawait, because Safari only honours a permission request made synchronously inside the user gesture. Call it straight from the click handler.subscribe,unsubscribe, andisSubscribedare the building blocks;refreshOnStartnever prompts.- They subscribe through the page's active service-worker registration — or the
registrationoption, where you pass whatnavigator.serviceWorker.register(...)returned. Otherwise they wait for a worker to activate for at mostregistrationTimeoutMs(10 s) and throw aWebPushRegistrationError;navigator.serviceWorker.readyalone never settles when no worker is registered, as on a dev server that does not serve one. fetchWebPushApi()targets theMapElarionWebPush()endpoints. To use the handlers over JSON-RPC instead, adapt the generated client:
const api: WebPushServerApi = {
getPublicKey: async () => (await rpc.webPush.publicKey({})).publicKey,
subscribe: async (subscription) => { await rpc.webPush.subscribe(subscription) },
unsubscribe: async (endpoint) => { await rpc.webPush.unsubscribe({ endpoint }) },
}Service worker
import { registerWebPushHandlers } from '@swimmesberger/elarion-webpush/sw'
registerWebPushHandlers(self, { icon: '/icons/192.png', badge: '/icons/badge.png' })pushshows the notification (a push that shows nothing is penalized by browsers, so an unreadable payload still shows its text).notificationclickfocuses a window already at the notification's URL, otherwise navigates an open app window there, otherwise opens one.pushsubscriptionchangere-subscribes when the push service rotates the subscription and sends the new one to the server. It authenticates with same-origin cookies; a bearer-token app passesapi: nulland relies onrefreshOnStart.
The module is ESM: bundle the worker (for example vite-plugin-pwa injectManifest) or register it with
navigator.serviceWorker.register('/sw.js', { type: 'module' }).
The same worker usually also makes the app installable: Installable apps (PWA) composes
registerShellRouter(self) from @swimmesberger/elarion-pwa/sw — offline shell and asset caching that never touch
the API — with these handlers, and shows how to bundle the worker into one script.
Limits
- One node sends from one database: the fan-out is in-process and bounded, which covers the 1–10 node tier. A mass-notification service (millions of devices, campaigns, analytics) is a job for a dedicated provider.
- Delivery is at-most-once per send and there is no read receipt — Web Push has none.
- A user who is online still gets the push; choosing between a client event and a push per call site is the application's decision for now.
Device identity
Pairing codes, per-device keys, and the connect-time HMAC handshake — the provisioning chain every device gateway needs, without hand-rolling the security-relevant parts.
Installable apps (PWA)
Turn "Add to Home Screen" into an app — an offline-capable service worker that never caches the API or a personal page, an install prompt with iOS instructions, and reloading into a new deploy without losing what the user was typing.