What the package defends against, how, and what remains your responsibility. Read it before going to production.
- Trust boundaries
- The endpoint is a capability URL
- SSRF: the endpoint is an outbound URL chosen by the client
- Registration: who may claim a browser
- Subscriber identity
- HTTP endpoints
- ActionUrl must carry its own authorization
- What the notification reveals
- Encryption at rest
- Client state marker and private responses
- Service worker
- Queues
- VAPID keys
- Supply chain
- GDPR
- Your checklist
| Boundary | Untrusted input | Controls |
|---|---|---|
| Browser → subscribe/unsubscribe routes | Endpoint, keys, content encoding, request size | Anonymous gate, CSRF, rate limit, media type, bounded body read, JSON depth, strict shape, value objects, allowlist, registration matrix, uniform 204 |
| Server → push service | The stored endpoint (outbound URL) | Allowlist (at registration and at delivery), canonical form, public-IP policy, DNS pinning, https only, no redirects, timeouts |
| Push service → server | HTTP status, Retry-After |
Single classifier; reason text never read; Retry-After capped at 3 600 s |
| Database → package | Rows (possibly tampered or restored from a backup) | Endpoint shape re-validated at reconstitution; AEAD with row-bound associated data when encryption is on |
| Application → package | Message text, click paths, action URLs, subscriber ids | Value objects with bounded, validated content |
| Push payload → service worker | Every field | Field allowlist, click path re-validation (origin + prefixes), asset host allowlist, same-origin POST only |
| Cache Storage → page | Navigation intent (writable by any script of the origin) | Marker equality, TTL, the page's own click-path check before location.assign() |
| Queue / broker | Message content | Scalars only, no endpoint or key; payload re-validated as contract v1 |
The endpoint identifies one browser installation, and together with your VAPID key pair (and the subscription keys, to encrypt a payload) it is all it takes to reach that device. Treat it as a secret: it must not leak, and it must never be the weak link if another secret does.
DeliveryOutcomecarries a status, a category and an HTTP status, never the push service's reason text or an exception message: both embed the request URL.- The Minishlink transport never calls
MessageSentReport::getReason(); Guzzle/Minishlink exceptions are reduced to their class name in logs. - Logs carry
subscription_id,push_service_hostand a 12-characterfingerprint(prefix of the endpoint's SHA-256): enough to correlate, useless to push. - Value objects never repeat the rejected value in exception messages; parameters holding an
endpoint, a key or a secret are
#[\SensitiveParameter]. __debugInfo()redactsSubscription,PushAddress,PushEndpoint,SubscriptionKeys,SubscriptionSnapshot,DeliveryTarget,VapidCredentials,EncryptionKey,PossessionProofand both persistence records. The Eloquent record hidesendpoint,p256dh,authfromtoArray()/toJson().- Commands (
webpush:test,web-push:test, purge) print counts, never an endpoint. - The read model (
SubscriptionView) exposes no endpoint and no key.
Your part: never log PushSubscription.toJSON() in the browser nor the raw request body on the
server. The Stimulus controller's web-push:subscribed event only carries
{ subscribed: true }: the endpoint is not copied into DOM events.
Layers, each of which would stop a different attack:
- Shape (
PushEndpoint): ≤ 512 characters (checked before any parsing); whitespace, control characters and backslashes refused before parsing (parser differentials);FILTER_VALIDATE_URL;httpsonly; no userinfo (https://fcm.googleapis.com@evil.example/); no explicit port; host not an IP literal. - Canonical form, refused not rewritten: the URL is rebuilt (
https://+ lowercase host + path + query) to compare; any difference is refused. A silently "fixed" endpoint would be unknown to the push service and would expire; refusing also removes a class of parser-confusion bypasses. - Allowlist (
AllowedPushServices): Applepush.apple.com, Googlefcm.googleapis.comandandroid.googleapis.com, Mozillaupdates.push.services.mozilla.com, Microsoftnotify.windows.com, plusextra_hosts. Exact match or suffix anchored on a dot (web.push.apple.comyes,notpush.apple.comandfcm.googleapis.com.evil.exampleno). Checked at registration and again at delivery: removing a host from the configuration stops deliveries at once, while existing rows stay readable and purgeable. - Public IP policy (
PublicIpPolicy): every resolved address must be globally routable (FILTER_FLAG_GLOBAL_RANGE: refuses 127/8, 10/8, 172.16/12, 192.168/16, 169.254/16, the 100.64/10 CGNAT range, fc00::/7...), after unwrapping IPv4-mapped and IPv4-compatible IPv6 (::ffff:169.254.169.254). A host with one non-public address is refused (mixed answers are what rebinding looks like).extra_hostsdo not bypass this check. - DNS pinning (
ResolvedHostPinning): resolve once, check, then force cURL to connect to the checked address withCURLOPT_RESOLVE(the hostname still drivesHostand TLS SNI). The Guzzle cURL handler is imposed (ext-curlis a hard requirement): the stream handler would silently ignore the pins. - Transport options:
allow_redirects: false,CURLOPT_PROTOCOLSandCURLOPT_REDIR_PROTOCOLS= https, TLS verification always on, timeout (15 s) and connect timeout (≤ 5 s).
Environment proxies neutralise pinning. Guzzle honours HTTPS_PROXY / HTTP_PROXY
(NO_PROXY): cURL then connects to the proxy, which resolves the push host itself, so the
checked address is no longer the one reached. If your egress goes through a mandatory proxy,
enforce the destination policy on the proxy and set dns_pinning: false knowingly.
The registration matrix lives in Subscription::claim(), the only entry point; the private
transitions guard their invariants again (InvariantViolated), so an application loading the
aggregate cannot bypass them.
| Known endpoint? | State | Claimant vs owner | auth presented |
Result |
|---|---|---|---|---|
| – | – | – | – | Host not allowed → refused (neutral) |
| no | – | – | – | Registered, then quota |
| yes | Active | same identified owner | any | Renewed (key rotation, e.g. Safari) |
| yes | Active | same anonymous owner | same | Renewed |
| yes | Active | same anonymous owner | different | Refused: "same anonymous owner" proves nothing |
| yes | Active | different (anonymous → identified included) | same | Reassigned, then quota of the new owner |
| yes | Active | different | different | Refused (the client must unsubscribe, then subscribe) |
| yes | Active | identified → anonymous | – | Refused: no downgrade |
| yes | Retired | same | (as above) | Reactivated, then quota |
| yes | Retired | different | same | Reactivated for the new owner (+ reassigned), then quota |
| yes | Retired | different | different | Refused |
| yes | Retired | identified → anonymous | – | Refused |
Proof of possession is the auth secret, compared in constant time (hash_equals). Refusals are
answered 204 like successes; the reason (refused_host_not_allowed, refused_auth_mismatch,
refused_downgrade, refused_anonymous_cap) is only logged. SubscriptionReassigned is
security-relevant: audit it.
The subscriber id must be stable and never reused. Never an e-mail address or a username: a
user changing address, and someone signing up with the old one, would inherit each other's
devices. The bridges never fall back on getUserIdentifier(); the Laravel default prefixes the
auth identifier with the morph alias or model class. Namespace ids when several user providers
exist (admin:1, customer:1). Without a valid resolver, Symfony throws on the first
authenticated request and Laravel fails at boot.
- Deny by default.
AnonymousGate(owned by the package, not by youraccess_controlor middleware) answers 403 before reading the body when there is no authenticated user and anonymous subscriptions are disabled. - Anonymous opt-in is fail-closed:
anonymous.enabledwithoutanonymous.rate_limiterrefuses to boot. The rate-limit key is computed by the package (IPv4 address, IPv6 /64: an IPv6 client owns 2^64 addresses). A global cap (max_active, default 10 000) bounds the table; beyond it registrations are refused, never evicted (evicting a stranger's device would let anyone flush the table). Anonymous subscriptions are only reachable throughEveryone. - CSRF on both JSON routes: Symfony header
X-CSRF-Token(token idweb_push), Laravel thewebgroup (X-CSRF-TOKEN). - Input, in order:
application/jsononly (415) → the body actually read is bounded to 4 096 bytes, chunked requests included (413) →json_decodedepth 4 (400) → exact shape (unknown fields refused) → value objects. Only package exceptions are caught; anything else surfaces as a bug. Bodies of error responses are empty. On Laravel,VerifyCsrfTokenparses the body before this bound: PHP and web server limits apply first. - Unsubscribe is always rate limited, on both bridges: it is public (no anonymous gate) and
hashes a proof on every call. Default 30 requests per minute per client network (IPv6 /64):
the
web_push_unsubscribesliding window the Symfony bundle declares (hencesymfony/rate-limiterbeing required), a package limiter on Laravel. Override it withroutes.unsubscribe_rate_limiter. - No oracle: unsubscribe and revoke answer the same way for an unknown endpoint, somebody else's and a wrong proof. Revoke and owner-based lookups filter on the owner in the query (no find-then-check).
- Unsubscribe by possession is accepted for any owner and without the anonymous gate: holding
authproves you are the device, and the only possible effect is removing that subscription.
A PostAction makes the service worker POST to a same-origin URL with the user's cookies,
without a CSRF token, possibly from the lock screen, i.e. from whoever holds the phone. The
endpoint must therefore:
- carry its own authorization: a signed, short-lived URL bound to the resource;
- be idempotent (a retried push may show the button twice);
- do nothing more than the button says (acknowledge, snooze).
Both bridges ship a signer behind the core port Application\Port\ActionUrlSigner
(sign(string $route, array $parameters, \DateInterval $validity): ActionUrl). The URL is
absolute and checked against the application Origin: a signer can never produce a
PostAction pointing to another origin.
Symfony (SymfonyActionUrlSigner: router + UriSigner + Origin; set
framework.router.default_uri so absolute URLs work in CLI and workers):
use RomainMillan\WebPushNotification\Application\Port\ActionUrlSigner;
use RomainMillan\WebPushNotification\Bridge\Symfony\Signing\SymfonyActionUrlSigner;
use RomainMillan\WebPushNotification\Domain\Message\Action\ActionLabel;
use RomainMillan\WebPushNotification\Domain\Message\Action\PostAction;
$message = $message->withAction(new PostAction(
ActionLabel::fromActionAndTitle('ack', 'Acknowledge'),
$actionUrlSigner->sign('alert_ack', ['id' => $alert->id], new \DateInterval('PT1H')),
));
// In the controller of alert_ack (POST, excluded from your CSRF checks),
// with SymfonyActionUrlSigner $signer injected:
if (!$signer->verify($request)) {
throw $this->createAccessDeniedException();
}verify() checks the signature and the expiry, and refuses a URL signed without expiry. On
Symfony 7.1+ the expiry is the native _expiration parameter of UriSigner; on 6.4, which has
none, it travels as a signed _web_push_expires parameter checked by verify() (never call
UriSigner::checkRequest() alone: on 6.4 it ignores the expiry). This 6.4 path is only exercised
by the lowest-dependencies CI job.
Laravel (LaravelActionUrlSigner, on URL::temporarySignedRoute()):
use RomainMillan\WebPushNotification\Application\Port\ActionUrlSigner;
use RomainMillan\WebPushNotification\Domain\Message\Action\ActionLabel;
use RomainMillan\WebPushNotification\Domain\Message\Action\PostAction;
$message = $message->withAction(new PostAction(
ActionLabel::fromActionAndTitle('ack', 'Acknowledge'),
app(ActionUrlSigner::class)->sign('alerts.ack', ['alert' => $alert->id], new \DateInterval('PT1H')),
));
// routes/web.php
Route::post('/alerts/{alert}/ack', AcknowledgeAlert::class)->name('alerts.ack')->middleware('signed');
// bootstrap/app.php: the worker sends no CSRF token
->withMiddleware(function (Middleware $middleware): void {
$middleware->validateCsrfTokens(except: ['alerts/*/ack']);
})The signed middleware validates the URL of the incoming request: its scheme and host must be
those of the APP_URL origin. Behind a reverse proxy, configure TrustProxies so the request is
seen as https:// on the public host, or every signed URL is refused.
- The title and body are displayed on the lock screen: never put a secret, an amount the user would not want shown, a one-time code or personal data of a third party in them.
- The payload is end-to-end encrypted (RFC 8291) between your server and the browser; the push service sees only its size, which the padding makes constant (see payload-contract.md).
- Icons and badges are displayed only from your origin or
asset_hosts: an arbitrary image URL in a payload would be a tracking pixel fired on every display.
Off by default. Without it, endpoints and auth secrets are plaintext secrets in your
database and in every backup: a dump combined with a leaked VAPID key lets anyone push to
your users' devices, and the endpoints identify their browsers.
web_push_notification:
encryption:
current: '%env(WEB_PUSH_ENCRYPTION_KEY)%' # "k2:<base64 of 32 bytes>"
previous: ['%env(WEB_PUSH_ENCRYPTION_KEY_PREVIOUS)%']php -r 'echo "k1:".base64_encode(random_bytes(32)), PHP_EOL;'- XChaCha20-Poly1305 (libsodium), a random 24-byte nonce per value, stored as
v1:<keyId>:<base64(nonce . ciphertext)>inendpoint,p256dhandauth. - Associated data = endpoint fingerprint + column name: a ciphertext copied onto another row or column fails to decrypt.
- Key format
keyId:base64: keyId 1–16 of[a-zA-Z0-9_-], key exactly 32 bytes, validated. Dedicated: never derived fromkernel.secret/APP_KEY, never the HMAC secret of the marker. - Rotation: set the new key as
current, move the old one toprevious. Values are re-encrypted with the current key when their row is next saved (registration renewals happen at most daily per active device); remove the old key once no row uses it. endpoint_hashandpush_hoststay in clear (lookups, unique index, device list).
- The marker tells the page and the worker which account the local worker state (navigation intent) belongs to, so a new user on a shared browser never inherits the previous user's click. It is an equality tag, not an authorization.
HMAC-SHA256(purposeKey, "client-state:" + subscriberId)truncated to 16 hex characters, wherepurposeKey = HMAC-SHA256(appSecret, "web-push-client-state-v1")(kernel.secret, or the decodedAPP_KEY). A purpose sub-key, never the root secret; derived from the identity, not the session (a re-login must not wipe a pending intent). Anonymous visitors get none.- The meta tag holds a CSRF token and that marker: the page is marked private
(
PrivateResponseListeneron Symfony addsprivate, no-store;MarkWebPushResponsePrivateon Laravel setsprivateand dropss-maxage). The JSON is encoded withJSON_HEX_TAG|JSON_HEX_AMP|JSON_HEX_APOS|JSON_HEX_QUOTthen HTML-attribute escaped.
The worker is the most privileged script of the origin.
/web-push-sw.jsis stateless (Symfonystateless(), Laravel outside any middleware group: no session, no cookie); its content never depends on who fetches it.- Configuration is injected as a JSON literal escaped for any context (
</script>, U+2028, quotes); headerstext/javascript; charset=utf-8,nosniff,Cache-Control: no-cache(fixes reach browsers on the next update check);Service-Worker-Allowed: /only when the route is below the root. - The worker accepts allowlisted payload fields only, re-validates click paths (origin +
click_prefixes), POSTs only same-origin, displays images only from allowed hosts. - The click trail kept for diagnostics never stores a path (Cache Storage is shared by origin,
not by user). Logout purges the worker state;
Clear-Site-Data: "storage"is not used because it unregisters the worker and destroys the subscription on Chrome.
Messenger messages and Laravel jobs hold scalars only: subscription id, expected subscriber id,
the v1 payload, TTL/urgency/topic. No endpoint, no key, so failure transports and
failed_jobs hold no capability URL. But the notification text (title, body, click path,
action URLs) sits in the broker until consumed or failed: protect and expire your transports
accordingly. A message whose subscription changed hands meanwhile is dropped, never delivered to
the new owner.
- Generate with
webpush:vapid:generate/web-push:vapid: printed, never written. The output may remain in shell history or CI logs. - Store the private key as a secret (Symfony secrets, vault, environment); never commit it. Only the public key reaches the page.
- Validated at boot/first use: public key 65-byte uncompressed P-256 point, private key 32 bytes,
subject
mailto:orhttps:. - Rotation invalidates every subscription until each browser comes back: push services
answer 401/403, classified
Permanent(vapid)and logged as critical, deliberately not treated as expiry (a mis-deployed key must not wipe your table). The page client detects the key change (sameApplicationServerKey) on its next sync, unsubscribes the old subscription and creates a new one. Devices that never come back keep failing until you remove them. - Only one key pair is supported.
assets/dist(the prebuilt worker and ESM bundles) is committed; CI rebuilds it from the sources withyarn install --immutableand fails on anygit diffofdist/.- GitHub Actions are pinned by commit SHA, the workflow has
permissions: contents: read, nopull_request_target. composer auditandyarn npm audit --all --recursive --severity highrun in CI.- The JS client is not published on npm: it ships in
assets/distinside the Composer archive, so there is a single artifact to trust. Recommended for maintainers: enable 2FA on Packagist and GitHub (see contributing.md). - Minimal dependencies:
minishlink/web-push, Guzzle, PSR interfaces,symfony/string.
- Storage limitation: schedule
webpush:purge/web-push:purgedaily: retired subscriptions go afterpurge.retired_after(30 days), anonymous ones afteranonymous.stale_after(90 days) without re-registration. - Right to erasure: call
RemoveAllSubscriptions::removeAllOf()when deleting an account. - Minimisation: no user agent, device label or IP is stored; the device list shows the push service and dates only. Expired subscriptions are retired automatically.
- The endpoint is personal data (it identifies a browser installation); encryption at rest is recommended.
-
WebPushSubscriber(or a resolver) returns a stable, non-reusable, namespaced id. - VAPID private key in a secret store, not in git.
- Encryption at rest enabled with a dedicated key.
- A shared lock store (Symfony
framework.lock, Laravel non-arraycache). -
click_prefixesnarrowed to your application paths. - Every
PostActionURL signed, short-lived, idempotent; the route exempted from CSRF only because it is signed. - No sensitive data in titles and bodies.
- Logout forms marked
data-web-push-logout. - Purge scheduled daily;
RemoveAllSubscriptionswired to account deletion. - Anonymous subscriptions left disabled unless needed (then a rate limiter and a sensible
max_active). - Queue transports protected (they carry the notification text).
- No environment proxy silently bypassing DNS pinning.