Register for this year’s #ChromeDevSummit happening on Nov. 11-12 in San Francisco to learn about the latest features and tools coming to the Web. Request an invite on the Chrome Dev Summit 2019 website

The Web Push Protocol

We've seen how a library can be used to trigger push messages, but what
exactly are these libraries doing?

Well, they're making network requests while ensuring such requests are
the right format. The spec that defines this network request is the
Web Push Protocol.

This section outlines how the server can identify itself with application
server keys and how the encrypted payload and associated data is sent.

This isn't a pretty side of web push and I'm no expert at encryption, but let's look through
each piece since it's handy to know what these libraries are doing under the hood.

Application server keys

When we subscribe a user, we pass in an applicationServerKey. This key is
passed to the push service and used to check that the application that subscribed
the user is also the application that is triggering push messages.

When we trigger a push message, there are a set of headers that we send that
allow the push service to authenticate the application. (This is defined
by the VAPID spec.)

What does all this actually mean and what exactly happens? Well these are the steps taken for
application server authentication:

This signed information is sent to the push service as a header in a POST request.

The push service uses the stored public key it received from
pushManager.subscribe() to check the received information is signed by
the private key relating to the public key. Remember: The public key is
the applicationServerKey passed into the subscribe call.

If the signed information is valid the push service sends the push
message to the user.

An example of this flow of information is below. (Note the legend in the bottom left to indicate
public and private keys.)

The "signed information" added to a header in the request is a JSON Web Token.

JSON web token

A JSON web token (or JWT for short) is a way of
sending a message to a third party such that the receiver can validate
who sent it.

When a third party receives a message, they need to get the senders
public key and use it to validate the signature of the JWT. If the
signature is valid then the JWT must have been signed with the matching
private key so must be from the expected sender.

There are a host of libraries on https://jwt.io/ that
can perform the signing for you and I'd recommend you do that where you
can. For completeness, let's look at how to manually create a signed JWT.

Web push and signed JWTs

A signed JWT is just a string, though it can be thought of as three strings joined
by dots.

The first and second strings (The JWT info and JWT data) are pieces of
JSON that have been base64 encoded, meaning it's publicly readable.

The first string is information about the JWT itself, indicating which algorithm
was used to create the signature.

The JWT info for web push must contain the following information:

{
"typ": "JWT",
"alg": "ES256"
}

The second string is the JWT Data. This provides information about the sender of the JWT, who
it's intended for and how long it's valid.

The aud value is the "audience", i.e. who the JWT is for. For web push the
audience is the push service, so we set it to the origin of the push
service.

The exp value is the expiration of the JWT, this prevent snoopers from being
able to re-use a JWT if they intercept it. The expiration is a timestamp in
seconds and must be no longer 24 hours.

In Node.js the expiration is set using:

Math.floor(Date.now() / 1000) + (12 * 60 * 60)

It's 12 hours rather than 24 hours to avoid
any issues with clock differences between the sending application and the push service.

Finally, the sub value needs to be either a URL or a mailto email address.
This is so that if a push service needed to reach out to sender, it can find
contact information from the JWT. (This is why the web-push library needed an
email address).

Just like the JWT Info, the JWT Data is encoded as a URL safe base64
string.

The third string, the signature, is the result of taking the first two strings
(the JWT Info and JWT Data), joining them with a dot character, which we'll
call the "unsigned token", and signing it.

The signing process requires encrypting the "unsigned token" using ES256. According to the JWT
spec, ES256 is short for "ECDSA using the P-256 curve and
the SHA-256 hash algorithm". Using web crypto you can create the signature like so:

A push service can validate a JWT using the public application server key
to decrypt the signature and make sure the decrypted string is the same
as the "unsigned token" (i.e. the first two strings in the JWT).

The signed JWT (i.e. all three strings joined by dots), is sent to the web
push service as the Authorization header with WebPush prepended, like so:

Authorization: 'WebPush <JWT Info>.<JWT Data>.<Signature>'

The Web Push Protocol also states the public application server key must be
sent in the Crypto-Key header as a URL safe base64 encoded string with
p256ecdsa= prepended to it.

Crypto-Key: p256ecdsa=<URL Safe Base64 Public Application Server Key>

The Payload Encryption

Next let's look at how we can send a payload with a push message so that when our web app
receives a push message, it can access the data it receives.

A common question that arises from any who've used other push services is why does the web push
payload need to be encrypted? With native apps, push messages can send data as plain text.

Part of the beauty of web push is that because all push services use the
same API (the web push protocol), developers don't have to care who the
push service is. We can make a request in the right format and expect a
push message to be sent. The downside of this is that developers could
conceivably send messages to a push service that isn't trustworthy. By
encrypting the payload, a push service can't read the data that's sent.
Only the browser can decrypt the information. This protects the user's
data.

Before we look at the specific steps to encrypt a push messages payload,
we should cover some techniques that'll be used during the encryption
process. (Massive hat tip to Mat Scales for his excellent article on push
encryption.)

ECDH and HKDF

Both ECDH and HKDF are used throughout the encryption process and offer benefits for the
purpose of encrypting information.

ECDH: Elliptic Curve Diffie-Hellman key exchange

Imagine you have two people who want to share information, Alice and Bob.
Both Alice and Bob have their own public and private keys. Alice and Bob
share their public keys with each other.

The useful property of keys generated with ECDH is that Alice can use her
private key and Bob's public key to create secret value 'X'. Bob can do
the same, taking his private key and Alice's public key to
independently create the same value 'X'. This makes 'X' a shared secret
and Alice and Bob only had to share their public key. Now Bob and Alice
can use 'X' to encrypt and decrypt messages between them.

ECDH, to the best of my knowledge, defines the properties of curves which allow this "feature"
of making a shared secret 'X'.

HKDF: HMAC based key derivation function

HKDF is an HMAC based key derivation function that transforms any weak key
material into cryptographically strong key material. It can be used, for
example, to convert Diffie Hellman exchanged shared secrets into key material
suitable for use in encryption, integrity checking or authentication.

The auth value should be treated as a secret and not shared outside of your application.

The p256dh key is a public key, this is sometimes referred to as the client public key. Here
we'll refer to p256dh as the subscription public key. The subscription public key is generated
by the browser. The browser will keep the private key secret and use it for decrypting the
payload.

These three values, auth, p256dh and payload are needed as inputs and the result of the
encryption process will be the encrypted payload, a salt value and a public key used just for
encrypting the data.

Salt

The salt needs to be 16 bytes of random data. In NodeJS, we'd do the following to create a salt:

const salt = crypto.randomBytes(16);

Public / Private Keys

The public and private keys should be generated using a P-256 elliptic curve,
which we'd do in Node like so:

You might be wondering what the Content-Encoding: auth\0 string is for.
In short, it doesn't have a clear purpose, although browsers could
decrypt an incoming message and look for the expected content-encoding.
The \0 adds a byte with a value of 0 to end of the Buffer. This is
expected by browsers decrypting the message who will expect so many bytes
for the content encoding, followed a byte with value 0, followed by the
encrypted data.

Our Pseudo Random Key is simply running the auth, shared secret and a piece of encoding info
through HKDF (i.e. making it cryptographically stronger).

Context

The "context" is a set of bytes that is used to calculate two values later on in the encryption
browser. It's essentially an array of bytes containing the subscription public key and the
local public key.

Before we encrypt our payload, we need to define how much padding we wish
to add to the front of the payload. The reason we'd want to add padding
is that it prevents the risk of eavesdroppers being able to determine
"types" of messages based on the payload size.

You must add two bytes of padding to indicate the length of any additional padding.

For example, if you added no padding, you'd have two bytes with value 0, i.e. no padding exists, after these two bytes you'll be reading the payload. If you added 5 bytes of padding, the first two bytes will have a value of 5, so the consumer will then read an additional five bytes and then start reading the payload.

const padding = new Buffer(2 + paddingLength);
// The buffer must be only zeros, except the length
padding.fill(0);
padding.writeUInt16BE(paddingLength, 0);

With these headers set, we need to send the encrypted payload as the body
of our request. Notice that the Content-Type is set to
application/octet-stream. This is because the encrypted payload must be
sent as a stream of bytes.

More headers?

We've covered the headers used for JWT / Application Server Keys (i.e. how to identify the
application with the push service) and we've covered the headers used to send an encrypted
payload.

There are additional headers that push services use to alter the behavior of
sent messages. Some of these headers are required, while others are optional.

TTL header

Required

TTL (or time to live) is an integer specifying the number of seconds
you want your push message to live on the push service before it's
delivered. When the TTL expires, the message will be removed from the
push service queue and it won't be delivered.

TTL: <Time to live in seconds>

If you set a TTL of zero, the push service will attempt to deliver the
message immediately, but if the device can't be reached, your message
will be immediately dropped from the push service queue.

Technically a push service can reduce the TTL of a push message if it
wants. You can tell if this has happened by examining the TTL header in
the response from a push service.

Topic

Optional

Topics are strings that can be used to replace a pending messages with a
new message if they have matching topic names.

This is useful in scenarios where multiple messages are sent while a
device is offline, and you really only want a user to see the latest
message when the device is turned on.

Urgency

Optional

Urgency indicates to the push service how important a message is to the user. This
can be used by the push service to help conserve the battery life of a user's device by only
waking up for important messages when battery is low.

The header value is defined as shown below. The default
value is normal.

Urgency: <very-low | low | normal | high>

Everything together

If you have further questions about how this all works you can always see how libraries trigger
push messages on the web-push-libs org.

Once you have an encrypted payload, and the headers above, you just need to make a POST request
to the endpoint in a PushSubscription.

So what do we do with the response to this POST request?

Response from push service

Once you've made a request to a push service, you need to check the status code
of the response as that'll tell you whether the request was successful
or not.

Status Code

Description

201

Created. The request to send a push message was received and accepted.

429

Too many requests. Meaning your application server has reached a rate
limit with a push service. The push service should include a 'Retry-After'
header to indicate how long before another request can be made.

400

Invalid request. This generally means one of your headers is invalid
or improperly formatted.

404

Not Found. This is an indication that the subscription is expired
and can't be used. In this case you should delete the `PushSubscription`
and wait for the client to resubscribe the user.

410

Gone. The subscription is no longer valid and should be removed
from application server. This can be reproduced by calling
`unsubscribe()` on a `PushSubscription`.

413

Payload size too large. The minimum size payload a push service must
support is 4096 bytes
(or 4kb).

Feedback

Was this page helpful?

Yes

What was the best thing about this page?

It helped me complete my goal(s)

Thank you for the feedback. If you have specific ideas on how to improve this page, please
create an issue.

It had the information I needed

Thank you for the feedback. If you have specific ideas on how to improve this page, please
create an issue.

It had accurate information

Thank you for the feedback. If you have specific ideas on how to improve this page, please
create an issue.

It was easy to read

Thank you for the feedback. If you have specific ideas on how to improve this page, please
create an issue.

Something else

Thank you for the feedback. If you have specific ideas on how to improve this page, please
create an issue.

No

What was the worst thing about this page?

It didn't help me complete my goal(s)

Thank you for the feedback. If you have specific ideas on how to improve this page, please
create an issue.

It was missing information I needed

Thank you for the feedback. If you have specific ideas on how to improve this page, please
create an issue.

It had inaccurate information

Thank you for the feedback. If you have specific ideas on how to improve this page, please
create an issue.

It was hard to read

Thank you for the feedback. If you have specific ideas on how to improve this page, please
create an issue.

Something else

Thank you for the feedback. If you have specific ideas on how to improve this page, please
create an issue.