Securing Web Push Notifications

ยท 1588 words ยท 8 minute read

Introduction ๐Ÿ”—

I recently added web push notifications to a personal project. I noticed that there is some work that application developers have to do to ensure the security of these push notifications. This post describes my considerations for securing web push notifications.

Context and setting ๐Ÿ”—

Most, if not all, web applications involve making some authenticated requests to the server. Typically, our user will provide their credentials (say, email and password) to the server. If the credentials are valid, the server will issue an authentication token that is persisted locally (e.g., as a cookie or in localStorage). Subsequent requests to the server will include this token. The server will then validate that the token is valid and, in the process, establish the identity of the user.

In general, the application can be in one of these five states:

  1. Before authentication: When the user starts the application but before they log in.
  2. Authenticated: This is the happy case after the user logs in.
  3. Logged out: When the user uses the log out feature in the application to log out.
  4. Client reset: The authentication token is removed from the application without going through the normal log-out flow. This can be done, for example, by clearing the browser’s data.
  5. Invalidated: When the server invalidates an authentication token. This is the case, for example, when the user uses the feature to log out from all other sessions.

We say that an application/endpoint/feature/etc is secure if, and only if, applications in state 2 can access authenticated resources.

Using the mechanism described above, any client-initiated requests are secure:

  • In states 1, 3, and 4, the application will not provide an authentication token.
  • In state 2, the application has a valid token that the server will accept.
  • In state 5, the application will provide a token, but it will be rejected by the server.

However, a push is, by definition, server-initiated, which will require extra handling. To see why that is the case, we need a quick refresher on how web push notifications work.

Recap: web push notifications ๐Ÿ”—

Web push notifications are powered by the Push API and the Notifications API. First, by using the Push API, we can get our clients to subscribe to a push service. Once the relevant permissions are granted, a subscription, which includes a URL, is provided to the client. The URL will be used by the server to push data to our client. The actual request is somewhat involved, so let’s assume that we have a helper function to do that:

// From the server
sendWebPush(subscriptionUrl, {
  data: 'Your order is now shipped.'
});

The client will then listen to the push event, which is invoked when the server sends a push:

// Client, the service worker specifically
self.addEventListener('push', (event) => {
  const data = event.text(); // Returns 'Your order is now shipped.'
});

Then we will use the Notifications API to show a notification:

// Client
self.addEventListener('push', (event) => {
  const message = event.text(); // Returns 'Your order is now shipped.'
  self.registration.showNotification(message);
});

Goal & problem ๐Ÿ”—

Our goal is to ensure that only applications in state 2 will see the notification message. As we shall see, applications in state 4 require the most handling. However, the good news is that we can leverage the existing authentication mechanism without any significant modifications. We will start with a very simple solution and improve from there.

Version 1: subscription belongs-to user ๐Ÿ”—

Since we will be sending personalized notifications, each web push subscription should be tied to a user:

id subscription_url user_id
001 https://example.com/browser-1/111 001
002 https://example.com/browser-2/aaa 002
003 https://example.com/browser-2/bbb 001

To send a push notification to a specific user, we will simply filter this table by the user_id and invoke each of the subscription URLs:

function notifyUser(userId, message) {
  // TODO: Don't do this, this is susceptible to SQL injection
  const urls = query(`
    SELECT
      subscription_url
    FROM subscriptions
    WHERE user_id = ${userId}
  `);

  urls.forEach((url) => {
    sendWebPush(url, {data: message});
  });
}

This implementation will send a notification to every browser that the user has ever logged in to. In other words, applications in states 2-5 will all receive the notification. This is too wide, we will revise our solution in the next few steps to restrict the set of applications that are receiving the notifications.

Version 2: subscription belongs-to session ๐Ÿ”—

The goal of this solution is to exclude applications in states 3 and 5 from receiving the notification. To do so, instead of associating subscriptions directly with the user, we will associate them with the authentication tokens. Let’s suppose that we store the authentication tokens in a table called sessions:

id authentication_token user_id
001 tokenxyz 001
002 tokenabc 002
003 tokenmno 001

The subscriptions table will then look like:

id subscription_url session_id
001 https://example.com/browser-1/111 001
002 https://example.com/browser-2/aaa 002
003 https://example.com/browser-2/bbb 003

When we invalidate a token, we will delete the corresponding subscription URLs.

The code to send a push notification now looks like this:

function notifyUser(userId, message) {
  // TODO: Don't do this, this is susceptible to SQL injection
  const urls = query(`
    SELECT
      subscriptions.subscription_url
    FROM session
    INNER JOIN subscriptions
    ON session.id = subscriptions.session_id
    WHERE session.user_id = ${userId}
  `);

  urls.forEach((url) => {
    sendWebPush(url, {data: message});
  });
}

With this modification, notifications will now be sent only to applications in states 2 and 4.

Version 3: Masked push payload ๐Ÿ”—

At the moment, we have been sending the notification message as the push payload. However, if we carefully think about state 4, we can conclude that there is no way for us to avoid sending a push to them. This is because the removal of the authentication tokens is done without the server’s knowledge. Hence, we can’t distinguish states 2 and 4. To solve this, we will have to send masked data as the push payload – something that, on its own, reveals no information about the notification itself. Upon receipt of this masked data, the client can make a request to the server using, if it has one, the authentication token. If the authentication token is present and valid, the server will return with the actual notification message.

There are many ways to construct the masked data. I will give some suggestions later. For now, let’s assume that we have a pair of functions called mask and unmask which will convert between the notification message and the masked data:

// Server
function notifyUser(userId, message) {
  // TODO: Don't do this, this is susceptible to SQL injection
  const urls = query(`
    SELECT
      subscriptions.subscription_url
    FROM session
    INNER JOIN subscriptions
    ON session.id = subscriptions.session_id
    WHERE session.user_id = ${userId}
  `);

  const maskedData = mask(userId, message);

  urls.forEach((url) => {
    sendWebPush(url, {data: maskedData});
  });
}

// This is the API handler. Assume that userId is injected by the framework using the provided authentication token
function getNotificationMessage(userId, maskedData) {
  const unmaskedData = unmask(data);
  if (unmaskedData == null) {
    return null;
  }

  if (unmaskedData.userId !== userId) {
    return null;
  }

  return unmaskedData.message;
}

The client-side push event listener will be a little different too, since the push payload is not the notification itself anymore.

// Client
self.addEventListener('push', (event) => {
  // Get the opaque data
  const maskedData = event.blob();

  // Request the server to convert the masked data to the notification message. This
  // function call will automatically inject the authentication token if one is present.
  const message = await getNotificationMessage(maskedData);

  if (message != null) {
    self.registration.showNotification(message);
  }
});

As mentioned previously, there are many ways to mask the data. However, one important criterion is that the masking and unmasking operations should be tied to the user to prevent another authenticated user from being able to see the notification message. Consider this series of events:

  1. Alice logs in to the application and enables push notification.
  2. Alice clears the browsing data, bringing the application to state 4.
  3. Bob logs in to the application.
  4. A push notification meant for Alice is sent to this application.

By right, the application should not see the notification data since it is for Alice. However, if the unmasking operation does not check the user ID, it would have shown the notification message to Bob.

One way to mask the message is to store it in the database and return a lookup key, say, the primary key:

function mask(userId, message) {
  return query(`
    INSERT INTO notifications (userId, message) VALUES (${userId}, ${message}) RETURNING id
  `);
}

function unmask(id) {
  return query(`
    SELECT
      userId,
      message
    FROM notifications
    WHERE id = ${id}
  `);
}

If we want to avoid storing the message, we could encrypt it using a key known only to the server:

function mask(userId, message) {
  const json = JSON.stringify({userId, message});
  return encrypt(json, SERVER_KEY);
}

function unmask(data) {
  const json = decrypt(data, SERVER_KEY);
  return JSON.parse(json);
}

Version 4: Cleaning up invalid subscriptions ๐Ÿ”—

With the previous solution, we have achieved the goal of securing our push notifications. However, our server will continue to push data to applications in state 4. Some browsers, especially Safari, are not happy when a push does not result in a notification being shown. This is a valid concern – otherwise, it opens up the possibility of tracking our users. These browsers will silently stop the push delivery after several pushes do not show a notification.

Thus, it is in our best interest to stop future push attempts as soon as we know that the application is in state 4. To do so, we can include the subscription (either the ID or the subscription URL) in the unmasked data. Once the getNotificationMessage request fails, we can delete the corresponding subscription to future pushes to this subscription.