Web Push delivers a short message to a browser even when your site is
closed - the Service Worker wakes up, decrypts the payload, and shows
an OS-level notification. Suprnova ships the protocol end-to-end:
VAPID key generation, AES128GCM payload encryption, the HTTP transport,
and a WebPushChannel that plugs into the notifications subsystem so
the same Notification you send to mail or database also lands as a
push.
Reach for this when you want to alert users in real time without an open WebSocket - order shipped, friend request, mention, balance posted. If the user is on a desktop browser with the site closed, web push is the only mechanism that reaches them; if they're on the site, Broadcasting is usually a better fit.
The API is behind the web-push Cargo feature, which is enabled by default.
Applications using default-features = false must enable web-push
explicitly.
The four pieces
Web Push has more moving parts than mail or database, because the spec (RFC 8030 + RFC 8291 + RFC 8292) splits identity, encryption, and transport across three contracts:
| Piece | What it is |
|---|---|
VapidKey / VapidSigner |
A P-256 ECDSA keypair used to sign JWTs that prove your server is who it claims to be |
WebPushClient |
The HTTP client that encrypts a payload, signs a VAPID JWT, and POSTs to the subscription's endpoint |
WebPushChannel |
The notifications-subsystem adapter that turns a Notification into a WebPushClient::send call |
SubscriptionInfo |
The opaque (endpoint, p256dh, auth) triple the browser hands you when a user subscribes - you store it; you don't generate it |
The bottom three layers - VapidKey, WebPushClient, the encrypted
POST - are re-exported from suprnova::web_push so applications never
need to depend on the underlying suprnova-web-push crate directly.
Generate a VAPID keypair
Web Push uses VAPID (Voluntary Application Server Identification) to let push services rate-limit and contact misbehaving senders. You need one P-256 keypair per application; the public key goes into your frontend so the browser can pin subscriptions to your server, and the private key stays on the server signing JWTs.
Generate one once, persist it, and reuse it forever:
use VapidKey;
let key = generate;
// Save the PEM somewhere durable - a secrets manager, a file the deploy
// pipeline mounts, an env-vars-as-files volume. You CANNOT regenerate
// this without invalidating every existing subscription.
let pem = key.to_pem?;
write?;
// The frontend needs the base64url-no-padding uncompressed public key.
// Hand this to your JS so `pushManager.subscribe()` can use it as
// `applicationServerKey`.
println!;
At boot, load the saved PEM:
use ;
let pem = read_to_string?;
let key = from_pem?;
let signer = new;
A VapidSigner produces JWTs but does not send anything - it's
purely a signing primitive. The next layer wraps it.
Build a WebPushClient
WebPushClient is the HTTP-side primitive: feed it a signer and a
contact URI ("how the push service can reach you if you misbehave"),
get back an object whose send method encrypts a payload, signs a
JWT, and POSTs to the subscription endpoint.
use Arc;
use ;
let signer = new;
// The subject MUST be a mailto: URI or an https: URL per RFC 8292 §2.1.
// Anything else is rejected at construction so a misconfigured deploy
// fails fast at boot - not silently after the first failed dispatch.
let client = new?;
let client = new;
Why Arc<WebPushClient>? WebPushClient wraps a VapidSigner which
wraps a private ES256KeyPair. None of those are Clone - private
keys shouldn't be casually duplicated - and constructing a fresh
signer for every channel registration would mean N independent VAPID
identities for the same application. Wrapping in Arc lets a single
signed identity back every registration and every concurrent delivery.
Endpoint policy
Subscription endpoints are user-derived data: the browser receives the URL from a remote push service when a user subscribes, and your server stores whatever the browser handed back. A maliciously stored subscription can point the HTTP POST anywhere reachable, turning the push sender into an SSRF gadget.
WebPushClient defaults to EndpointPolicy::Strict, which checks the
endpoint before anything is sent:
- The scheme must be
https. - The host must be a name, not an IP address.
- The name must not be
localhost, a cloud metadata name such asmetadata.google.internal, or a name under an RFC 2606 reserved top-level domain (.localhost,.local,.internal,.test,.example,.invalid). - Every address the name resolves to must be on the public internet. The
client looks the name up and refuses the send when any answer is a
loopback, private, link-local, multicast, unspecified, documentation
or reserved address, or a cloud metadata address (
169.254.169.254,fd00:ec2::254,100.100.100.200). Private covers RFC 1918, the RFC 6598 shared address space and IPv6 unique local addresses.
The last check matters because the name in a stored endpoint is not
yours: a name someone else registered can resolve to 127.0.0.1 or to
10.0.0.5. After the check, the client connects to one of the addresses
it checked. TLS and the Host header still use the name, and the name
is not looked up a second time, so a DNS answer that changes between the
check and the connection (DNS rebinding) cannot move the request.
Real push services (FCM, Mozilla Autopush, Apple's web.push.apple.com)
pass every check.
A refused address returns WebPushError::RefusedDestination. It names
the class of the address, such as AddressClass::Private, and never the
address itself. A lookup that fails, or a name that resolves to no
address, returns WebPushError::Lookup, which is_retryable() reports as
retryable.
The connection goes to a checked address when the client builds its own
transport, with WebPushClient::new or
WebPushClient::with_client_builder. That transport sends through no
proxy. A proxy set on the builder is dropped, and HTTPS_PROXY and the
other proxy variables are not read: a proxy receives the name and
resolves it again, where the check cannot follow.
A client you pass to WebPushClient::with_client resolves the name
itself when it connects, so the check cannot hold the connection to the
addresses it checked. Under Strict, a send over such a client returns
WebPushError::UnpinnedTransport before anything is sent, until you
call .allow_unpinned_destinations() on the WebPushClient. After that
call, the endpoint and every address the name resolves to where your
application runs are still checked, and the connection goes wherever
your client sends it. See Custom transport.
For local integration tests against a wiremock mock server you have
to opt out:
use ;
let client = new?
.with_endpoint_policy;
Do not use AllowAny in production. The strict checks exist to keep
a tampered subscriptions table from being weaponised.
If your push service runs on a private network, Strict refuses it,
because its name resolves to a private address. The only policy that
reaches it is AllowAny, which turns off every check, so use it only when
your application, not a browser, decides which endpoints are stored.
Endpoint allowlist
Strict refuses private and local destinations, but any public host
passes it, so a tampered subscription can still make your server POST to a
host of the attacker's choice. A browser subscribes only through its
vendor's push service, so when your subscriptions come from browsers you
can name every host they reach. EndpointPolicy::Allowlist refuses every
other host:
use ;
let allowlist = new?;
let client = new?
.with_endpoint_policy;
These are the origins of the known browser push services:
| Browser | Push service origin |
|---|---|
| Firefox | https://updates.push.services.mozilla.com |
| Chrome and most other Chromium browsers | https://fcm.googleapis.com |
| Safari | https://web.push.apple.com |
| Microsoft Edge | https://*.notify.windows.com |
The allowlist runs every check of Strict, with the same errors. It checks
the endpoint's origin after the checks of the URL's shape and before the
host name is looked up, so an endpoint at a host outside the list causes
no DNS query and no connection. An endpoint the list admits then goes
through the address check and connects to a checked address, as under
Strict.
An origin is https://host or https://host:port, with no path, no
trailing slash, no query and no user name. The host is a name, not an IP
address. An endpoint matches an origin when its scheme is https, its host
is the same name in any letter case, and its port is the same; an endpoint
without a port uses 443. EndpointAllowlist::new returns
InvalidAllowlist, which quotes the entry, for any other form, and for an
empty list.
Microsoft's push service spreads its subscriptions over many hosts, such
as wns2-par02p.notify.windows.com, so its origin needs a wildcard. A host
can start with the label *., which matches exactly one label:
https://*.notify.windows.com admits wns2-par02p.notify.windows.com but
not a.b.notify.windows.com or notify.windows.com. The wildcard is the
whole first label and appears once, and the name after it has two labels
or more, so https://*.com is refused.
An endpoint at another origin returns WebPushError::OriginNotAllowed
before anything is sent. It carries the origin, such as
https://push.example.net, and never the path of the endpoint, which is
the token that reaches the browser. is_retryable() returns false.
To send one message under a policy other than the client's own, such as
from a client that sits behind an Arc shared with other senders, call
WebPushClient::send_with_policy.
Set the allowlist for the notifications channel
WebPushChannel reads the allowlist from WEB_PUSH_ENDPOINT_ORIGINS, a
comma-separated list of origins in the form above:
WEB_PUSH_ENDPOINT_ORIGINS=https://updates.push.services.mozilla.com,https://fcm.googleapis.com,https://web.push.apple.com,https://*.notify.windows.com
When the variable names one or more origins, every send of the channel
goes under EndpointPolicy::Allowlist with them, whatever policy you built
the client with, AllowAny included. Unset, or blank, the client's own
policy applies. Spaces around an entry and empty entries are ignored.
WebPushChannel::new reads the variable and returns an error that names it
when an entry is not an origin, so a typo fails at boot, where you build
the channel, instead of leaving the channel open to every host.
Set the variable when your application stores subscriptions from browsers. Leave it unset when your push service runs on a private network, because the allowlist keeps the strict address check, which refuses it.
Custom transport
WebPushClient::new applies a 30-second per-request timeout. If you
need a different transport policy, such as pinned TLS or a shorter
timeout, pass a reqwest::ClientBuilder to
WebPushClient::with_client_builder. Your builder options are honoured,
but three settings are replaced after yours, because each would let a
send reach an address the strict policy did not check:
- Redirects are disabled. A validated endpoint that answers 3xx must not bounce the POST to an unvalidated URL.
- The DNS resolver is replaced by the one that connects a checked name to its checked addresses, and looks any other name up as usual.
- No proxy is used, whether you set one on the builder or the environment names one.
A name you map with ClientBuilder::resolve or
ClientBuilder::resolve_to_addrs still connects where you mapped it,
not to the checked address. reqwest applies a mapping only to a host
name equal to the name you mapped, compared without case, so a stored
endpoint reaches your address only at a name you chose, and cannot add
a mapping of its own. Map only a name whose address you want every
endpoint at that name to reach. ClientBuilder::unix_socket and
ClientBuilder::connector_layer also decide where a connection goes,
and the library cannot see them, so use them only to reach an address
you chose.
use Client;
use Duration;
use WebPushClient;
let client = with_client_builder?;
WebPushClient::with_client takes an already-built client whose
redirect policy and name resolution the library cannot inspect. Under
the default Strict policy, a send over such a client is refused before
any I/O until you accept both by name:
.allow_unconfined_redirects(), when the client does not follow redirects. Without it, the send returnsWebPushError::UnconfinedRedirects..allow_unpinned_destinations(), when you accept that the client connects wherever it resolves the name. Without it, the send returnsWebPushError::UnpinnedTransport.
A push service you can reach only through a proxy needs such a client:
use Duration;
use WebPushClient;
let http = builder
.proxy
.redirect
.timeout
.build?;
let client = with_client?
.allow_unconfined_redirects
.allow_unpinned_destinations;
The strict checks still run on the answers your application gets for the
name. When your application cannot resolve the name at all, every send
returns WebPushError::Lookup, and the only policy that sends is
AllowAny, which turns off every check.
Wire WebPushChannel into notifications
The raw WebPushClient::send works - but the way you actually send
push notifications in Suprnova is through the
Notifications subsystem. A Notification declares
vec!["webpush"] in its channels(), a Notifiable recipient
returns a JSON-encoded SubscriptionInfo from route_for("webpush"),
and the bound NotificationDispatcher does the fan-out.
use Arc;
use ;
let client: = new;
// ttl_secs: how long the push service holds an undelivered message.
// 86_400 (24h) is a reasonable default for non-urgent notifications;
// drop to 60 for "act right now" alerts where a stale message is
// worse than no message.
let webpush = new;
let dispatcher = new
.register_channel;
set_dispatcher?;
WebPushChannel::new returns an error when WEB_PUSH_ENDPOINT_ORIGINS
holds an entry that is not an origin. See
Set the allowlist for the notifications channel.
register_channel is last-write-wins on the channel's name(), so
tests can swap in a stub without affecting the production binding.
Define a notification
A push-bound notification is the same shape as any other Suprnova
notification - declare "webpush" in channels() and put whatever
JSON you want delivered into data():
use ;
use Notification;
The data() JSON is what your Service Worker receives. Pick a stable
shape and document it for the frontend - Suprnova doesn't impose one,
because notification UI is a frontend concern.
Route the recipient
A Notifiable returns the route for each channel it supports.
For Web Push, that route is the JSON-encoded SubscriptionInfo -
exactly what the browser produced via PushSubscription.toJSON(),
stored verbatim:
use Notifiable;
Returning None causes the dispatcher to skip the channel silently -
useful for users who haven't subscribed to push but still get email.
Send it
Synchronous:
use Notify;
let user = find.await?.unwrap;
send.await?;
Queued - pre-resolves the subscription route at queue time so the worker doesn't need to re-load the user:
queue.await?;
For Notify::queue to work, register the notification's factory at
boot so the worker can rebuild the JSON payload into the typed
notification:
?;
;
Behind the scenes, queued dispatch builds a SendNotificationJob
carrying (notification_name, payload, per_channel_routes, channels).
The worker re-hydrates the notification, looks up WebPushChannel by
name on the bound dispatcher, and calls deliver(route, ¬ification) -
the same code path as the synchronous Notify::send.
The browser side
Suprnova does not ship a JavaScript SDK - the browser side is plain Web Push API. The flow your frontend needs to implement:
- Register a Service Worker.
- Ask the user for permission.
- Subscribe via
pushManager.subscribe({ userVisibleOnly: true, applicationServerKey: <your VAPID public key> }). - POST
subscription.toJSON()to a Suprnova endpoint that stores it on the user row.
// Service Worker registration (somewhere in your app entrypoint)
const registration = await navigator..;
Your Suprnova endpoint receives the JSON, validates the shape, and
stores it on the user - the string is opaque to your server, but it
must be the exact JSON the browser produced (the
SubscriptionInfo type uses Deserialize to parse it later):
use ;
pub async
The Service Worker decrypts the push payload and renders the notification:
// /sw.js
self.;
self.;
Payload limits
The Web Push spec caps each encrypted payload at 4096 bytes total.
Suprnova rejects plaintexts larger than 3992 bytes (the cap minus the
~85-byte AES128GCM encryption overhead) at encrypt time so the
failure surfaces in your code, not in a 413 from the push service.
A Notification whose serialized data() exceeds that limit
fails in the channel's deliver: the client returns
WebPushError::Encryption, and the channel reports it as an internal
error.
For anything larger - a long message body, a thumbnail - send a short notification carrying a URL the Service Worker fetches on click. That's both faster (no encryption on a multi-KB payload) and more flexible (the fetch can return whatever shape you want).
Dead subscriptions
When the push service returns 404 or 410, the subscription is dead -
the user uninstalled the browser, revoked the permission, or cleared
storage. WebPushChannel treats this as a non-fatal warn:
WARN webpush subscription gone (404/410); caller should remove
channel=webpush host=fcm.googleapis.com endpoint_sha256=4e9bdab8bbe7189c
Dispatch returns Ok(()) because the notification reached a terminal
state - there's no recipient to retry against. Your application is
expected to act on the warn and remove the subscription row. Suprnova
ships the warn; it does not auto-prune the subscriptions table for you.
The warning has no endpoint field. The path of an endpoint is the token
that reaches the browser, so the log keeps it out. host is the host of
the endpoint, and endpoint_sha256 is the first 16 hexadecimal digits of
the SHA-256 of the endpoint as you stored it. To find the row, compute
the same digest of each stored endpoint and compare:
use ;
Subscriptions that cannot be used
A stored subscription can be unusable before any request leaves your
server. The client then returns WebPushError::InvalidSubscription, and
nothing is sent. The text of the error names the rule that refused the
subscription. Two groups of checks raise it:
- The endpoint. It is no URL, or the
Strictendpoint policy refuses it because it is nothttps, has no host, names an IP address in place of a host, or names a host that is no push service (localhost, a cloud metadata host, or a reserved name such as.localor.internal). When its host name resolves to an address the policy refuses, the error isWebPushError::RefusedDestination, which carries the class of the address in place of a text. When the allowlist does not name its origin, the error isWebPushError::OriginNotAllowed, which carries the origin. - The keys.
p256dhis no base64url, does not decode to 65 bytes, is not in uncompressed form or is no point of the P-256 curve.authis no base64url or does not decode to 16 bytes.
The stored data is wrong, so a retry cannot help. is_retryable() returns
false. WebPushChannel handles these errors as it handles a gone
subscription: it logs a warning and dispatch returns Ok(()), so a queue
does not send the job again. For a refused address, the reason is the
class, such as its host name resolves to a private address. For an origin
outside the allowlist, it is the origin, such as
its origin https://push.example.net is not in the endpoint allowlist.
When that origin belongs to a push service your users subscribe through,
add it to WEB_PUSH_ENDPOINT_ORIGINS instead of removing the
subscription.
WARN webpush subscription cannot be used; caller should remove
channel=webpush host=10.0.0.7 endpoint_sha256=533bb6dc756981d0 reason=subscription endpoint host '10.0.0.7' is an IP literal; real push services use named hosts
The warning carries the reason, the host and the digest of the endpoint,
as the warning for a gone subscription does, and no endpoint field.
A stored route that is no subscription at all gets the same warning:
text that is no JSON, or JSON without an endpoint or without keys. The
reason is the kind of the mistake and its position, such as
the value is not JSON (line 1, column 1). It never quotes the route,
which holds the secret of the subscription. This warning has no host and
no digest, because the route has no endpoint to take them from.
Your application removes the subscription row, as it does for a gone
subscription. Dispatch succeeds, so no NotificationFailed event fires;
you learn of it from the log. When you call WebPushClient::send
yourself, match on WebPushError::InvalidSubscription:
match client.send.await
Retries and Retry-After
When the push service returns a transient 5xx, 408, or 429, the
underlying WebPushError::PushServiceRejected carries the parsed
Retry-After hint (delta-seconds form only - HTTP-date form returns
None):
use WebPushError;
match client.send.await
The Retry-After hint is capped at 24 hours so a hostile server
can't park a worker on a multi-year sleep.
PushServiceRejected carries the status and the hint, and no part of the
body the push service sent. The body is remote data, and an error is what
your code logs, stores and shows. The client reads the first 200 bytes of
the body and writes them to its debug log, with control characters
escaped, and WebPushChannel fails the delivery with an error that names
the status alone, such as
WebPushChannel: push service rejected (status 500).
When you use Notify::queue, the queue retries the job for you. A
PushServiceRejected that carries a Retry-After hint leaves
WebPushChannel::deliver as a FrameworkError::RateLimited with that
hint. The worker then retries the job after the hinted time instead of the
delay from the job's backoff schedule. A job that the push service refused
with 429 goes back when the service said it would take it, not earlier and
not later. The worker caps the wait at 24 hours
(queue::retry::RETRY_HINT_CEILING).
The attempt still counts against Notification::max_tries, and the job
dead-letters when the attempts run out. Every other failure, including a
rejection that carries no Retry-After hint, keeps the delay from
Notification::backoff.
Telemetry
The notifications dispatcher wraps the fan-out in a
notification.dispatch info span tagged with the notification name
and channel count. Each successful delivery emits a
NotificationSent event; failures emit NotificationFailed carrying
the channel name, route, and error string. Wire any of those into
your metrics/log pipeline the same way you wire other framework
events - see Events.
A dead subscription emits a structured WARN with channel="webpush",
the host and the digest of the endpoint, and the notification name. A
subscription that cannot be used emits a WARN with the same fields and
the reason. Neither has the endpoint. That's the signal to scrape for an
automated subscription cleanup job.
Why Suprnova diverges
Laravel's WebPush driver is a community package
(laravel-notification-channels/webpush) - not in core, separately
versioned, opinionated about ORM. Suprnova bakes Web Push into the
framework because the protocol is well-defined and the encrypted
HTTP POST is too small a contract to wrap in a third-party
abstraction. The notifications subsystem keeps the surface uniform:
the same Notification you send to mail or database also lands as a
push, no driver matrix, no separate config tree.
We also surface the strict-endpoint policy by default. The Laravel community package leaves SSRF protection to the application; we take the position that "the endpoint came from user data" is the shape of every Web Push subscription, and the safe default belongs in the framework, not in your code.
The retry classification (is_retryable, retry_after) is exposed
as typed methods on WebPushError rather than as a magic constant
table in the queue layer. The queue still owns retry policy - the
error tells you whether a retry could succeed and how long to wait;
the queue decides whether and when to dequeue again. Separating the
two means your custom retry strategies (exponential backoff,
jittered, capped) don't have to special-case Web Push.
Testing
Stand up a wiremock server, point a WebPushClient at it with
EndpointPolicy::AllowAny, and assert on the requests it receives:
use Arc;
use ;
use ;
use ;
async
Leave WEB_PUSH_ENDPOINT_ORIGINS unset in a test that uses AllowAny.
When it names origins, the channel sends under the allowlist, which
refuses the mock server's http address.
For end-to-end tests that don't care about the encrypted bytes,
Notify::fake() (covered in Notifications)
captures the dispatch without running the channel - faster, no
mock server, no encryption round-trip.
Reference
- Primitives:
suprnova::VapidKey,suprnova::VapidSigner,suprnova::VapidClaims - Client:
suprnova::WebPushClient,suprnova::EndpointPolicy,suprnova::EndpointAllowlist,suprnova::PushResponse,suprnova::SubscriptionInfo - Error:
suprnova::WebPushError-.is_retryable(),.retry_after(),WebPushError::SubscriptionGone,WebPushError::InvalidSubscription,WebPushError::RefusedDestination,WebPushError::OriginNotAllowed,WebPushError::Lookup,WebPushError::PushServiceRejected;suprnova::InvalidAllowlistfor an origin list that does not parse - Environment:
WEB_PUSH_ENDPOINT_ORIGINS(see Environment Variables) - Refused address classes:
suprnova::AddressClass - Encoding:
suprnova::ContentEncoding(Aes128Gcm; 3992-byte plaintext cap) - Channel:
suprnova::WebPushChannel - Facade:
suprnova::Notify - Queue job:
suprnova::SendNotificationJob - Factory registration:
suprnova::notifications::register_notification_factory
Next
- Notifications - the multi-channel dispatcher that
WebPushChannelplugs into - Mail - the email-channel counterpart for users without push
- Broadcasting - real-time delivery for users who are on the site
- Queues - how
Notify::queuebacksSendNotificationJob - Events - listening for
NotificationSent/NotificationFailedto watch deliveries
