< Return home
Sanjay Sharma’s avatar
Sanjay SharmaSenior Software EngineerSydney, Australia
Polling is fine but does every tab need to poll? Electing leader tab and Broadcasting messages across tabs.Nov 16th 2023
Session Expiry using Browser Broadcast-channel

Introduction

Session management is one of the crucial security aspects of a web application. OWASP's Application Security Verification Standard (ASVS) states a high-level requirement to verify that sessions are invalidated when no longer required and timed out during periods of inactivity.

Session timeouts are kept in place so that an attacker has a short window of time to perform a malicious act on behalf of the victim user and a much-reduced timeframe in which to steal a victim’s data. Session timeouts are dependent on how critical the application and its data are.

For a user, having a session suddenly expire can be a jarring experience. Good design practices require that we notify the user that this is about to happen, and give them an opportunity to react (either by saving their current work, or more usefully, allowing them to extend the session, possibly requiring re-authentication in the process). We’d like to show the user some sort of pop-up or in-page banner that their session is expiring, and give them these options.

How do we handle sessions?

At Bugcrowd, our system is built using a service-oriented architecture. The central authentication system (Central Auth) is responsible for authentication and session management for all the other services in our ecosystem. Every authenticated request has to go through Central Auth service to make sure they have a valid session. Central Auth is also responsible for showing warning banners when the session is about to expire.

When a session expires, we redirect users to a re-authentication screen for entering their login credentials. We have a React component that fetches the current state of the session from the Central Auth server via an API call. Depending on the number of seconds remaining until the session will expire, the component shows a warning banner indicating the session is about to expire and allows the user to extend their session (depending on the maximum session length allowed for the user - this can vary).

If the user chooses not to extend their session, or the maximum session length has elapsed, then the component automatically redirects the user to the re-authentication screen on session expiry.

All user-facing services in our ecosystem must have this feature. To make this approachable for service implementation teams, we decided to internally publish the npm React package which can be easily imported by all downstream services that need it. Out of the box, all these services get a React component mounted on every authenticated page.

session expiry banner re-authentication screen

The React component which fetches session data from the backend uses a polling mechanism. We chose not to invest in a client-server pub-sub model using socket programming (and the security requirements involved in communicating via sockets) in favor of the simplicity of polling. A polling interval of 15 seconds felt sufficiently real-time when the session changed on the server.

The Problem

Polling was working fine for us and the session expiry banner and re-authentication screens were displayed in almost real time when the session changed on the server. Unfortunately, when a user had multiple tabs open, all the tabs were doing session polling of their own. For instance, if there were 50 active users each with 5 tabs open at a given time, then the Central Auth server would get 50 * 5 = 250 requests every 15 seconds on the auth-session endpoint. As the number of users or tabs grows, so does the load on our Central Auth server. This could also cause problems for users, who would bump into our rate limiting rules as their number of tabs grew (especially if those tabs were doing other asynchronous polling work!)

The situation led us to investigate: Is it possible to restrict session polling to one tab, worker or process, and have all of the Bugcrowd tabs receive session information propagated from a single source? This would mean a single session expiration check per 15s polling interval per user, regardless of the number of tabs they have open.

The Solution

As it turns out, we already were using a package to share information across tabs. Bugcrowd allows SAML login via an Identity Provider (referred to as an IdP; e.g. Okta SSO, Azure, various other implementations). Reauthentication for those users should happen on the IdP login page.

SAML login has its own story but for our context - during re-authentication we open a new tab that directs the user to the IdP login screen. After login happens the tab broadcasts success or failure of SAML login back to the original tab which prompted the user to re-authenticate. We used the broadcast-channel npm package to achieve this.

The second part of the puzzle is solved - we can broadcast data to many tabs. Now to solve the first part: namely, that only one tab should be polling for session expiry at a time. The tagline of the package helpfully mentioned what we needed - LeaderElection. Et voila!

broadcast channel banner

This package combines two WHATWG HTML standard APIs to achieve what we want. The first API, as mentioned, is the Broadcast Channel API (the BroadcastChannel npm package itself is named after the Broadcast Channel API - this seems confusing, but the npm package provides support for older browsers). The second useful API is the WebLock API.

We only want one of our Bugcrowd tabs polling the Central Auth session expiry endpoint every 15 seconds. To do this, each tab will attempt to become the leader by “locking” a named, conceptual resource representing the leadership role. The first tab to achieve this lock becomes the leader. Any other tab attempting to lock this resource will have to wait until the current leader gives up this lock (usually, by that tab being closed).

The leader will be responsible for handling the session expiry request/response cycle, and for subsequently broadcasting the session expiry information to the other tabs.

In broad strokes, the WebLock API works like this:

  • A lock is requested with an arbitrary name
  • Any code that subsequently requests that lock has to wait
  • Work is done asynchronously while holding the lock
  • The lock is automatically released when the task is done (ala async/await)
  • Other code requesting the same lock can then obtain the lock.
  • Thus only one tab or worker can have this named lock at a given time

This package utilizes the WebLock API to elect a Leader Tab. The WebLock API uses Leader Election algorithm to elect and re-elect the leader.

const channel = new BroadcastChannel<Session>(‘session-channel’)
const elector = createLeaderElection(channel)

 // Wait for tab to become leader, only one-tab can become leader
 elector.awaitLeadership().then(() => {
   console.log(I am a leader tab’)
 })

We utilized this package to elect the leader tab. Only the leader tab is able to execute the session polling code, but becomes responsible for broadcasting the session data to its followers.

First, we made our react component create a session channel to use to post session data to the channel if it is rendered in the leader tab. On load, all tabs try to become the leader, but only one is successful. The other tabs constantly wait for the lock to be released so they can become the leader - which they won’t, unless the leader releases the lock (e.g. by being closed).

The follower tabs are listening to this session-channel, which they can then use to perform logic relating to the session (e.g. displaying a modal to ask the user to extend their session, or updating some UI components about session duration, etc.)

Creating a broadcast channel

const channel = new BroadcastChannel<Session>(‘session-channel’)
const elector = createLeaderElection(channel)

Acquiring the leadership… or trying to

useEffect(() => {
 // Wait for tab to become leader, only one-tab can become leader
 elector.awaitLeadership().then(() => {
   setIsLeader(true)
   setSessionPollEnabled(true)
 })
}, [])

Leader tab posts the polled Session whenever new polled data is available

if (isLeader && polledSession) {
 channel.postMessage(polledSession)
}

Follower tabs listen to the channel and stop session polling once a message is received.

const onMessageHandler = (message: Session | undefined) => {
 if (!isLeader && message) {
   setSession(message)
   setSessionPollEnabled(false) // If we’re not the leader, we should stop polling
 } else {
   setSessionPollEnabled(true) // We’ve potentially become the leader
 }
}

useEffect(() => {
 channel.addEventListener('message', onMessageHandler)

 return () => channel.removeEventListener('message', onMessageHandler)
}, [isLeader])

Note that by default, followers do still do their own session polling, until they receive a message from a leader over the broadcast channel. This is to cover the case where a brand-new tab has been opened, and has not yet been notified that there is an existing leader (it hasn’t received a message on the session channel). In this scenario, a brand-new tab might miss a session expiry that is about to occur if it wasn’t doing its own polling (an admittedly rare but still plausible scenario).

Browser Compatibility

Broadcast channel is compatible with almost every browser available today, even with the ones that do not have native broadcast channel or WebLock API support. IndexedDB is used to share data across tabs/processes if the browser does not have support for native broadcast-channel api but has support for WebWorkers. For older browsers without WebWorker support, localStorage is used to propagate the data across tabs.

In the absence of the WebLock API on older browsers, this package falls back to its own message-based leader election algorithm for electing a leader tab.

Win Win

We now have only one tab doing the session poll; the rest of the tabs can now get the session information propagated via the leader tab. Follower tabs are now using offline data from the session channel.

The following graph shows a decline in network calls on our production backend server after the feature was deployed. The requests per second to this endpoint have almost halved after the deployment.

decline on network requests

Who else uses it?

The sponsor of this package - RxDb uses it internally. RxDb (Reactive Db) is an offline-first, realtime, client-side javascript database. The database connects to the server database and replicates server data into itself. This replication only happens on the leader tab, the rest of the tabs receive data propagated via internal broadcast channel. See

Tanstack Query, one of the popular libraries for fetching, caching, synchronizing and updating server state in the frontend world has an experimental feature for broadcasting and syncing the state between browser tabs/windows. This is powered by the same broadcast-channel package.

Limitations

The broadcast channel only works per-origin. Tabs with different sub-domains are treated as different origins. Each subdomain must have its own leader tab doing the session-poll. broadcast-channel how it works

Summary

We were able to reduce network calls to our backend server by almost half just by using a simple npm package which depends on browser APIs that come by default on user browsers. Clients with high latency will benefit the most as they will make fewer network calls from their devices. Users with a large number of tabs open are also less likely to hit rate-limiting rules (including rules that restrict access for excessive numbers of GET requests).

It's a win-win for both client and server.

Edited by - Andy White