--- title: Web Push description: Send browser push notifications with flare over VAPID, only to https push service hosts on push.allowed_hosts and without redirects, and manage VAPID keys. section: services order: 130 --- # Web Push [flare](../../modules/flare/README.md) sends browser push notifications. Push is a different channel from realtime: realtime reaches pages that hold an open connection, while a push goes to the browser vendor's push service, which wakes the browser even when no page is open. flare is written on the standard library: the RFC 8291 payload encryption and the RFC 8292 VAPID authorization are implemented in the package, with no Web Push library. ## Subscriptions belong to the application When a browser subscribes, the frontend posts its `PushSubscription` (the endpoint URL and the `p256dh` and `auth` keys) to an application route, and the application stores it in its own table. flare never reads the database. Code that sends a push passes a `flare.Subscription` to the `flare.Pusher` that `flare.From` returns through `flare.Service.Pusher`. For the operator commands below, the application also publishes a `flare.SubscriptionSource` on the app, which reads a user's stored subscriptions. ## Sending `flare.Pusher.Send` encrypts the payload for the subscriber and posts it to the endpoint with the VAPID `Authorization` header. `flare.SendOptions` sets the `TTL` (default `push.ttl`), `Urgency` and `Topic` headers: ```go src=modules/flare/example_test.go#ExampleVAPIDPusher_Send // A stand-in push service: 201 for a live subscription, 410 for one the // browser dropped. push := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { if r.URL.Path == "/gone" { w.WriteHeader(http.StatusGone) return } fmt.Println("push service got", r.Header.Get("Content-Encoding"), r.Header.Get("TTL"), r.Header.Get("Urgency")) w.WriteHeader(http.StatusCreated) })) defer push.Close() keys, err := flare.GenerateVAPIDKeys() // websockets:generate-vapid-keys if err != nil { fmt.Println(err) return } cfg := flare.Config{ Enabled: true, PublicKey: keys.PublicKey, PrivateKey: keys.PrivateKey, Subject: "mailto:admin@example.com", TTL: time.Hour, AllowedHosts: []string{"127.0.0.1"}, // production keeps the default push services } // The test server's client trusts its certificate. pusher := flare.NewVAPIDPusher(cfg, push.Client()) ctx := context.Background() payload := []byte(`{"title":"New comment","body":"Someone replied to your post"}`) for _, endpoint := range []string{ push.URL + "/live", push.URL + "/gone", "https://push.attacker.example/steal", "http://127.0.0.1/plain", } { sub, err := browserSubscription(endpoint) if err != nil { fmt.Println(err) return } err = pusher.Send(ctx, sub, payload, flare.SendOptions{Urgency: "normal"}) switch { case err == nil: fmt.Println("sent") case errors.Is(err, flare.ErrSubscriptionGone): fmt.Println("gone: delete the subscription") case errors.Is(err, flare.ErrEndpointNotAllowed): fmt.Println("refused before connecting") default: fmt.Println("error:", err) } } // Formatting the keys never prints the private key. fmt.Println(strings.Contains(fmt.Sprintf("%v %#v", keys, keys), keys.PrivateKey)) // Output: // push service got aes128gcm 3600 normal // sent // gone: delete the subscription // refused before connecting // refused before connecting // false ``` - A 2xx answer is success. - 404 and 410 return `flare.ErrSubscriptionGone`: the browser unsubscribed, so delete the stored subscription. - Any other status returns a `flare.StatusError` with the code, never the response body. - A payload over `flare.MaxPayloadSize` (3993 bytes) returns `flare.ErrPayloadTooLarge`. - While `push.enabled` is false, nothing is sent and `flare.ErrPushDisabled` is returned. A send is one HTTP request with a 10-second timeout. Send from a queued job when a request would otherwise wait for it; see [Queued jobs](jobs.md). ## Endpoint safety Endpoints come from browsers, so they are untrusted URLs. flare sends only to `https` endpoints whose host is on `push.allowed_hosts`, checks this before it opens a connection, and never follows a redirect, so a push service cannot bounce the request to another host. A refused endpoint returns `flare.ErrEndpointNotAllowed`, which names the host but never the endpoint path. The default allowlist, `flare.DefaultAllowedHosts`, covers Firebase Cloud Messaging, Mozilla autopush, Apple and Windows push. `*.example.com` matches any subdomain but not `example.com` itself: ```go src=modules/flare/example_test.go#ExampleHostAllowed allowed := []string{"fcm.googleapis.com", "*.push.apple.com"} for _, host := range []string{"fcm.googleapis.com", "api.push.apple.com", "push.apple.com", "evil.example"} { fmt.Println(host, flare.HostAllowed(host, allowed)) } // Output: // fcm.googleapis.com true // api.push.apple.com true // push.apple.com false // evil.example false ``` Keep the default unless you know a browser your users run pushes through another service. ## VAPID keys A push service accepts a push only when it carries a token signed with the application's VAPID key pair. Generate the pair once: ```sh ./bin/acme websockets:generate-vapid-keys ``` It prints `SUMMER_PUSH__PUBLIC_KEY=...` and `SUMMER_PUSH__PRIVATE_KEY=...` lines to set in the environment. With `--update` it saves the keys to the environment's `overrides.yaml` instead. Keep the private key out of committed files. flare never writes the private key to a log or an error, and `flare.VAPIDKeys` and `flare.Config` redact it when printed. The frontend needs the public key to subscribe; serve it from a route of your own. Set `push.subject` to a `mailto:` or `https:` contact address for the push services, and `push.enabled` to `true`: ```yaml enabled: true subject: mailto:admin@example.com ``` in `config/push.yaml`. `websockets:test-push ` prints the push configuration without the key values, lists the user's subscriptions from the published `flare.SubscriptionSource`, and sends each one a test notification: ```sh ./bin/acme websockets:test-push 42 ```