· 6 min read · Olakunle Irantiola
Web Push from Go’s standard library
Implementing RFC 8291 encryption and VAPID with zero dependencies — and proving it against the spec.
- go
- cryptography
- web push
- open source
My trading engine needs to reach my phone when something matters: an order fills, a risk gate halts trading, a drawdown limit trips. I didn’t want to build a native app for that, and I didn’t want a push SDK — and its dependency tree — inside the process that places orders.
Web Push solves the first problem. It delivers notifications to Android browsers and, since iOS 16.4, to web apps added to an iPhone’s home screen. The second problem turned out to be easy too: the whole protocol fits in a few hundred lines of Go on the standard library. This is how it works, the two traps worth knowing about, and how I proved the implementation matches the spec byte for byte.
The code is open source: github.com/lexy10/webpush.
The moving parts
When a page calls pushManager.subscribe(), the browser returns a subscription with three values:
- endpoint — a URL on the browser vendor’s push service (Google’s FCM for Chrome, Mozilla’s for Firefox, Apple’s for Safari).
- p256dh — the subscriber’s P-256 public key.
- auth — a 16-byte secret.
To deliver a message, the server POSTs to the endpoint. Two things have to be true of that request:
- The push service must know who is sending. That’s VAPID (RFC 8292): a signed token proving the sender holds the private key matching the public key the browser subscribed with.
- The push service must not be able to read the message. That’s message encryption (RFC 8291), carried in the
aes128gcmcontent coding (RFC 8188).
1. VAPID is a JWT — with one trap
The token is a JSON Web Token signed with ES256. Its claims are small: aud is the origin of the push endpoint, exp is at most 24 hours ahead (I use 12), and sub is a mailto: or https: contact the push service can use if your traffic misbehaves.
The trap is the signature format. JWS expects an ES256 signature as the raw concatenation of r and s, each padded to 32 bytes. Go’s ecdsa.SignASN1 returns DER, which push services reject. Using ecdsa.Sign and writing both integers into fixed-width halves fixes it:
input := jwtHeader + "." + b64.EncodeToString(claims)
digest := sha256.Sum256([]byte(input))
r, s, err := ecdsa.Sign(rand.Reader, k.private, digest[:])
if err != nil {
return "", err
}
sig := make([]byte, 64)
r.FillBytes(sig[:32])
s.FillBytes(sig[32:])
return "vapid t=" + input + "." + b64.EncodeToString(sig) + ", k=" + k.public, nilThat’s all a JWT library would have done here. You don’t need one.
2. Encryption: two HKDFs and a delimiter
Every message gets a fresh P-256 key pair on the server side. The rest is key derivation:
- ECDH between the ephemeral private key and the subscriber’s
p256dhgives a shared secret. - HKDF mixes that secret with the subscriber’s
authsecret, binding both public keys into the info string, to produce 32 bytes of input keying material. - A random 16-byte salt goes through HKDF again to derive a 16-byte AES key and a 12-byte nonce.
- The payload is sealed with AES-128-GCM.
Go has had crypto/ecdh for a while, and crypto/hkdf joined the standard library in Go 1.24, so every primitive is a function call away:
prkKey, _ := hkdf.Extract(sha256.New, shared, authSecret)
ikm, _ := hkdf.Expand(sha256.New, prkKey, "WebPush: info\x00"+string(uaPub)+string(asPub), 32)
prk, _ := hkdf.Extract(sha256.New, ikm, salt)
cek, _ := hkdf.Expand(sha256.New, prk, "Content-Encoding: aes128gcm\x00", 16)
nonce, _ := hkdf.Expand(sha256.New, prk, "Content-Encoding: nonce\x00", 12)The body the push service receives is an 86-byte header followed by the ciphertext:
| Bytes | Field |
|---|---|
| 16 | salt |
| 4 | record size (4096) |
| 1 | key id length (65) |
| 65 | the ephemeral public key |
| … | AES-128-GCM ciphertext and 16-byte tag |
The second trap is small and silent: aes128gcm requires a padding delimiter at the end of each record’s plaintext, and the last record uses 0x02. Leave it out and the browser rejects the record — no notification, no error on your side. One byte:
record := make([]byte, 0, len(payload)+1)
record = append(record, payload...)
record = append(record, 0x02)
return gcm.Seal(header, nonce, record, nil), nilOne more requirement from the RFC’s security section: a received public key must be checked to be a real point on P-256 — skipping that is the classic invalid-curve weakness. ecdh.P256().NewPublicKey performs the check for you, which is one more reason to stay on the standard library.
3. Proving it against the RFC
Crypto that “seems to work” isn’t good enough. A browser that silently drops a message looks exactly like a bug in your code, and you can’t step through Apple’s push service.
RFC 8291 helps: its appendix publishes a worked example with every input and every intermediate value — both key pairs, the auth secret, the salt, and the final bytes on the wire. The only thing stopping a test from reproducing it is randomness, so the package splits encryption in two: the public Encrypt generates the ephemeral key and salt, then calls an internal seal that takes them as arguments. The test feeds seal the RFC’s values and compares the output byte for byte:
as, _ := ecdh.P256().NewPrivateKey(asPrivate)
got, err := seal([]byte("When I grow up, I want to be a watermelon"), uaPublic, authSecret, as, salt)
if !bytes.Equal(got, want) {
t.Fatalf("ciphertext differs from RFC 8291")
}It matched on the first run. That test is worth more than any amount of manual testing against real browsers, because it pins the implementation to the specification rather than to whatever one browser happens to tolerate.
4. The production details
Getting a notification onto a screen is the easy half. Running it for real needs a few more decisions.
Size. A push service only has to accept 4096 bytes of body. Take away the 86-byte header, the delimiter and the 16-byte GCM tag and you’re left with 3,993 bytes of plaintext. The package rejects anything larger up front, instead of letting the push service fail it later.
Dead subscriptions. Users revoke permission and browsers rotate subscriptions. A 404 or 410 from the push service means the subscription is gone for good, so the client returns a dedicated ErrGone and the caller deletes it. Everything else comes back as a StatusError with the status code, the response body and — for 429 — the parsed Retry-After.
Delivery hints. TTL says how long the push service should hold a message for an offline device. Urgency: high asks it to wake the device now. Topic lets a newer message replace an older undelivered one, so a phone that comes back online sees the latest position, not twelve stale ones.
Server-side request forgery. This one is easy to miss. The endpoint URL comes from the browser, which means it comes from whoever is calling your subscribe API. Without a check, someone can register http://169.254.169.254/latest/meta-data as their “endpoint” and have your server make requests into your own cloud network. The client only posts to HTTPS URLs on known push services — FCM, Mozilla, Apple, Windows — unless you explicitly opt out:
func KnownEndpoint(endpoint string) bool {
u, err := url.Parse(endpoint)
if err != nil || u.Scheme != "https" || u.Host == "" || u.User != nil {
return false
}
host := strings.ToLower(u.Hostname())
for _, h := range pushServices {
if host == h || strings.HasSuffix(host, "."+h) {
return true
}
}
return false
}What I took from it
The standard library is enough. ECDH, HKDF, AES-GCM and ECDSA are all in crypto/*. The package has no require lines in its go.mod, so there is nothing to audit but Go itself — exactly what I want next to an order router.
Implement specs against their own test vectors. When an RFC publishes worked examples, make them your first test. Design the code so randomness can be injected, and the hardest class of bug — “it works with Chrome but not Safari” — mostly disappears.
Small protocols deserve small code. Web Push sounds like something you buy as a service. It’s one HTTP request, one key agreement and one signature.
The package is MIT-licensed at github.com/lexy10/webpush. It needs Go 1.25 or later.