---
source_url: https://www.pubnub.com/docs/use-cases/sports-media-entertainment/fan-behavior-management
title: Mute and ban a disruptive fan
updated_at: 2026-09-30T07:20:08.000Z
---

# Mute and ban a disruptive fan

## Documentation index

To discover more PubNub resources:

1. Fetch [PubNub's llms.txt](https://www.pubnub.com/llms-full.txt) for a list of available pages in Markdown format.
2. Identify relevant URLs from that index.
3. Fetch the target pages.

Do not assume a path exists, always check the index first.

This tutorial builds fan behavior control for match-day chat with [Access Manager](https://www.pubnub.com/docs/security/access-control/overview.md) on the PubNub JavaScript SDK. By the end, a token service grants a fan read and write on `game.chat`, mutes the fan by revoking every writable token it has issued that fan and replacing it with a read-only one, then bans the fan by revoking every token it has issued that fan, period. The fan's own client observes each change through its status listener. Every token in this tutorial is minted server-side with the keyset's secret key, and a client that holds the secret key itself has no restriction worth enforcing.

`authorizedUserId` binds a token to one User ID, so the token only authorizes requests made as that User ID. That's not the same as making a stolen token harmless. A User ID isn't a secret: it's readable from the token itself, and a client sets its own by configuration, the same way this tutorial's `fan.js` sets `userId: 'fan-42'`. Anyone holding a copy of `fan-42`'s token can configure their own client with `userId: 'fan-42'` and use it exactly as `fan-42` could. What actually keeps a fan from acting as someone else is that your server, not the token, decides who receives which token in the first place. The User ID binding only limits what a given token can be used for once issued. Treat every token as a secret: deliver it only over TLS from your authenticated server, keep its TTL as short as the session tolerates, and revoke it the moment a session ends or a fan is removed, rather than relying on the binding alone.

## What you'll build

`server.js` is your token service. It grants `fan-42` a token with `read` and `write` on `game.chat` through Access Manager, and you pass that token to `fan.js`, which subscribes and publishes. To mute the fan, `server.js` revokes every writable token it issued and grants a read-only one. The fan can still read the chat, but PubNub rejects their next publish. To ban the fan, `server.js` revokes every token, and `fan.js`'s status listener reports `PNAccessDeniedCategory` without a restart.

```mermaid
sequenceDiagram
    participant Server as server.js
    participant AM as Access Manager
    participant Fan as fan.js
    participant Chat as game.chat

    Server->>AM: grant read and write token for fan-42
    Server-->>Fan: token, passed on the command line
    Fan->>Chat: subscribe and publish, allowed
    Server->>AM: mute, revoke writable tokens and grant read-only token
    Fan->>Chat: publish with read-only token, rejected
    Server->>AM: ban, revoke every token
    AM-->>Fan: PNAccessDeniedCategory on status listener
```

## Before you begin

You need:

1. Node.js 22 or later.
2. A PubNub account and a keyset. A keyset is the set of publish, subscribe, and secret keys that identifies your application to the PubNub network. Follow [Set up your account](https://www.pubnub.com/docs/architecture/authentication/set-up-your-account.md) if you don't have one yet.
3. [Access Manager enabled](https://www.pubnub.com/docs/security/access-control/configure-access-control.md#enable-access-manager) on the keyset. Without it, any client holding your keys already has full access, and there's nothing for a token to restrict.
4. The **Revoke v3 Token** setting turned on in the same **ACCESS MANAGER** section of your keyset settings. [Configure access control](https://www.pubnub.com/docs/security/access-control/configure-access-control.md#enable-token-revocation) is where you turn it on, and revoking a token fails without it.

You also need the publish key, subscribe key, and secret key from that keyset.

The secret key grants privileged access to your PubNub application. It must remain on a trusted server and must never be included in client applications.

## Set up the project

Create a directory and install the SDK:

```bash
mkdir fan-behavior
cd fan-behavior
npm init -y
npm install pubnub
```

Add `"type": "module"` to `package.json`. Create two files: `server.js`, your token service, which holds the keyset's secret key, and `fan.js`, the fan's own client, which never sees it.

`server.js`:

import PubNub from 'pubnub';
const server = new PubNub({  publishKey: 'YOUR_PUBLISH_KEY',  subscribeKey: 'YOUR_SUBSCRIBE_KEY',  secretKey: 'YOUR_SECRET_KEY',  userId: 'moderation-service',});

`fan.js`:

import PubNub from 'pubnub';
const fanClient = new PubNub({  publishKey: 'YOUR_PUBLISH_KEY',  subscribeKey: 'YOUR_SUBSCRIBE_KEY',  userId: 'fan-42',});

Replace `YOUR_PUBLISH_KEY`, `YOUR_SUBSCRIBE_KEY`, and `YOUR_SECRET_KEY` with the values from your keyset.

`server.js` runs once per command, from a terminal, as `node server.js <command> [userId]`. `fan.js` runs once and keeps running, as `node fan.js <token>`.

```mermaid
flowchart TB
    TS["<b>Token service</b><br/>your server"]
    AM["<b>Access Manager</b><br/>checks the token on<br/>every request"]
    RW["<b>Fan client</b><br/>publishes and subscribes"]
    RO["<b>Fan client</b><br/>subscribes only"]
    DEN["<b>Fan client</b><br/>cannot connect"]
    CHAT["<b>game.chat</b>"]

    TS --> AM
    AM -->|"1 · read and write"| RW
    AM -->|"2 · read only"| RO
    AM -->|"3 · revoked"| DEN
    RW --> CHAT
    RO --> CHAT
    DEN -.->|"rejected"| CHAT

    class CHAT emphasis
    class AM muted
```

Access Manager checks the token on every request to `game.chat`, and your token service decides which token each fan holds. A read-and-write token lets the fan subscribe and publish. A read-only token lets the fan subscribe but rejects a publish. PubNub rejects a revoked token outright. The client only carries the token it was given and never decides what it allows.

## Track every issued token

A mute or a ban only works if your server knows every token it has already handed that fan, so it can revoke the ones that no longer belong. A single new "current" token isn't enough by itself: a fan who keeps an earlier, wider token can go on using it right through a narrower one you issued afterward, unless you revoke the earlier one too.

```javascript
import fs from 'node:fs';

// A real token service tracks issued tokens and each fan's status in a database.
// This tutorial persists the same information in a JSON file next to the script,
// so `grant`, `mute`, `unmute`, and `ban` share state across separate `node server.js`
// invocations without needing a database just to run the tutorial.
const stateFilePath = new URL('./fan-state.json', import.meta.url);

function loadState() {
  try {
    return JSON.parse(fs.readFileSync(stateFilePath, 'utf8'));
  } catch {
    return {};
  }
}

function saveState(state = {}) {
  fs.writeFileSync(stateFilePath, JSON.stringify(state, null, 2));
}
```

Add this to `server.js`. A production token service keeps this same information, which tokens are outstanding for which fan, and whether that fan is active, muted, or banned, in a database, so it survives a restart and scales across more than one server process. This tutorial persists it in a JSON file next to the script instead, so the four commands below share state across separate `node server.js` invocations without asking you to stand up a database just to follow along.

## Let the fan into the chat

Grant the fan read and write access to `game.chat` from your token service.

```javascript
async function grantChatAccess(userId = '') {
  const state = loadState();
  const entry = state[userId] ?? { status: 'active', tokens: [] };

  if (entry.status === 'banned') {
    console.log(`${userId} is banned, so no token was issued`);
    return;
  }

  const canWrite = entry.status !== 'muted';

  try {
    const token = await server.grantToken({
      ttl: 60,
      authorizedUserId: userId,
      resources: {
        channels: {
          'game.chat': { read: true, write: canWrite },
        },
      },
    });

    entry.status = entry.status ?? 'active';
    entry.tokens = [...entry.tokens, { token, write: canWrite }];
    state[userId] = entry;
    saveState(state);

    console.log(`token that allows ${canWrite ? 'reading and writing' : 'reading'} chat:`, token);
  } catch (error) {
    const status = error instanceof Error && 'status' in error ? error.status : undefined;
    console.error(`Granting chat access failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
  }
}
```

Add this to `server.js`. `authorizedUserId` binds the token to `fan-42` specifically, so Access Manager rejects any request made with this token by a client configured with a different `userId`. This function also checks the fan's tracked status first: a banned fan gets no token at all, and a muted fan gets a read-only one even from this call, so asking for a fresh grant is never a way around a mute still in effect. Every token needs a time to live (TTL), and PubNub enforces a minimum and a maximum on how long one can last. Refer to [Access Manager](https://www.pubnub.com/docs/security/access-control/overview.md) for the exact bounds. This example grants 60 minutes.

## Watch for access changes before you connect

Register the fan's status listener before the fan's client subscribes to anything.

```javascript
fanClient.addListener({
  status: (event) => {
    if (event.category === 'PNConnectedCategory') {
      console.log('connected to game.chat');
    } else if (event.category === 'PNAccessDeniedCategory') {
      console.log('this fan may no longer write to', event.affectedChannels);
    }
  },
});
```

Add this to `fan.js`, before the code from [Apply the token and start chatting](#apply-the-token-and-start-chatting). This listener reports on the fan's subscription, not on individual publish calls. When a revoke reaches the token the fan is subscribed with, the subscription's next request is rejected and arrives here as `PNAccessDeniedCategory`, naming `game.chat` in `affectedChannels`. A rejected publish never arrives here. It rejects the publish call's own promise, which the next step catches. Registering it first means it's already watching by the time the subscription in the next step opens, rather than racing to attach after traffic could already be flowing. Refer to [monitoring connection status](https://www.pubnub.com/docs/architecture/connection-management/monitor-and-respond-to-connection-status-changes.md) for how the rest of a status listener works.

## Apply the token and start chatting

Parse the token from the command line, apply it, then connect and try to chat.

```javascript
const suppliedToken = process.argv[2];

if (!suppliedToken) {
  console.error('usage: node fan.js <token>');
  process.exit(1);
}

fanClient.setToken(suppliedToken);
```

```javascript
const subscription = fanClient.channel('game.chat').subscription();
subscription.subscribe();

try {
  const response = await fanClient.publish({
    channel: 'game.chat',
    message: { text: 'Come on!' },
  });
  console.log('chat message published at timetoken:', response.timetoken);
} catch (error) {
  const status = error instanceof Error && 'status' in error ? error.status : undefined;
  console.error(`Publishing to game.chat failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
}
```

Add both of these to `fan.js`, after the listener from [Watch for access changes before you connect](#watch-for-access-changes-before-you-connect). `process.argv[2]` is whatever you pass after `fan.js` on the command line, so `node fan.js <token>` is what actually applies a real token, rather than a hard-coded placeholder. The publish call is wrapped in its own `try`/`catch`. A rejected publish shows up right there, as a rejected promise, not on the status listener. The status listener reports what happens to the fan's standing *subscription* instead, a separate, longer-lived thing from any one publish call. From here, the client's only job regarding this token is to hold it, send it with every call, and ask your server for a new one before it expires. The client never decides what the token allows. It only carries what your server already decided.

## Mute the fan

Revoke every writable token this fan currently holds, then issue a narrower one.

```javascript
async function muteFan(userId = '') {
  const state = loadState();
  const entry = state[userId] ?? { status: 'active', tokens: [] };

  if (entry.status === 'banned') {
    console.log(`${userId} is already banned, so there is nothing left to mute`);
    return;
  }

  const writableTokens = entry.tokens.filter((issued = { token: '', write: false }) => issued.write);
  const stillValid = [];

  for (const issued of writableTokens) {
    try {
      await server.revokeToken(issued.token);
      console.log('revoked an outstanding writable token');
    } catch (error) {
      const status = error instanceof Error && 'status' in error ? error.status : undefined;
      console.error(`Revoking a writable token failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
      // Keep tracking a token you couldn't revoke, so the next mute or ban retries it.
      stillValid.push(issued);
    }
  }

  // Record the mute before issuing anything new, so a later grant can't hand out write access.
  entry.status = 'muted';
  entry.tokens = [...entry.tokens.filter((issued = { token: '', write: false }) => !issued.write), ...stillValid];
  state[userId] = entry;
  saveState(state);

  if (stillValid.length > 0) {
    console.error(`${stillValid.length} writable token(s) are still valid. Run mute again to retry revoking them.`);
  }

  try {
    const token = await server.grantToken({
      ttl: 15,
      authorizedUserId: userId,
      resources: {
        channels: {
          'game.chat': { read: true },
        },
      },
    });

    entry.tokens = [...entry.tokens, { token, write: false }];
    state[userId] = entry;
    saveState(state);

    console.log('token that allows reading chat but not writing to it:', token);
  } catch (error) {
    const status = error instanceof Error && 'status' in error ? error.status : undefined;
    console.error(`Muting the fan failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
  }
}
```

Add this to `server.js`. A mute here is not a separate feature. It looks at every token this fan currently holds according to your tracked state, revokes each one that carries `write`, and only then issues a new token with `write` left out, so the fan keeps `read` and keeps seeing the chat. Revoking the old tokens is what actually enforces the mute. Issuing a narrower one on its own would do nothing if the fan simply kept using a wider token you'd already handed out. The mute is recorded before the new token is issued. If a revoke fails, that token stays in the tracked list and the command prints how many writable tokens are still valid, so running `mute` again retries them. Don't treat a mute as enforced until every writable token is revoked.

Refer to [Grant, change, and revoke permissions](https://www.pubnub.com/docs/security/access-control/manage-permissions.md#revoke-a-token) for what revocation does and does not undo, including that a revoked token can't be re-enabled. That page also notes that revocation can take up to about a minute to take effect, because PubNub caches a token's non-revoked state for that long. Until it does, a request made with the just-revoked token can still succeed. Once it does, that same request fails with a `403`. An older writable token keeps working until the revoke reaches it, even if the fan is also holding the new read-only token. When the 15-minute read-only token expires, the fan doesn't get `write` back. They have no valid token until your server issues another one, and [Let the fan into the chat](#let-the-fan-into-the-chat) keeps issuing read-only tokens for as long as the fan's tracked status is `muted`. The same check is what makes refresh safe: a muted fan who asks your token service for a new token gets another read-only one, and a banned fan gets none.

## Notice the mute on the fan's client

Restart `fan.js` with the muted token, then try to publish again.

```bash
node fan.js <mutedToken>
```

The listener from [Watch for access changes before you connect](#watch-for-access-changes-before-you-connect) is already registered, so no new code is needed here. This time the publish attempt in [Apply the token and start chatting](#apply-the-token-and-start-chatting) is rejected, and your `catch` block logs it directly. Your UI should treat a rejected publish carrying an access-denied status as a sign to stop letting the fan type, rather than retry the same publish, since retrying a rejected token reproduces the same rejection.

## Ban the fan

Revoke every token this fan currently holds, read-only and writable alike.

```javascript
async function banFan(userId = '') {
  const state = loadState();
  const entry = state[userId] ?? { status: 'active', tokens: [] };

  const stillValid = [];

  for (const issued of entry.tokens) {
    try {
      await server.revokeToken(issued.token);
      console.log('revoked an outstanding token');
    } catch (error) {
      const status = error instanceof Error && 'status' in error ? error.status : undefined;
      console.error(`Revoking a token failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
      // Keep tracking a token you couldn't revoke, so the next ban retries it.
      stillValid.push(issued);
    }
  }

  entry.status = 'banned';
  entry.tokens = stillValid;
  state[userId] = entry;
  saveState(state);

  if (stillValid.length > 0) {
    console.error(
      `${userId} is banned, but ${stillValid.length} token(s) are still valid. Run ban again to retry revoking them.`,
    );
  } else {
    console.log(`${userId} is banned. Every outstanding token, read-only and writable, is now revoked.`);
  }
}
```

Add this to `server.js`. This is the same tracked-token list [Mute the fan](#mute-the-fan) uses, except every entry gets revoked, not only the writable ones, and the fan's tracked status becomes `banned`, so a later call to [Let the fan into the chat](#let-the-fan-into-the-chat) refuses to issue this fan anything at all until you decide otherwise. With `fan.js` still running on the muted token, this revokes it too. Shortly after, without you restarting `fan.js`, its status listener logs a rejection on the same `PNAccessDeniedCategory` category, once the revoke propagates.

## Bring the fan back

Lift a mute or a ban by granting a fresh token.

```javascript
async function unmuteFan(userId = '') {
  const state = loadState();
  const entry = state[userId] ?? { status: 'active', tokens: [] };

  // This is the one command that clears a ban as well as a mute. Calling it is always
  // a deliberate decision by whoever operates server.js, never a side effect of anything
  // else in this tutorial.
  try {
    const token = await server.grantToken({
      ttl: 60,
      authorizedUserId: userId,
      resources: {
        channels: {
          'game.chat': { read: true, write: true },
        },
      },
    });

    entry.status = 'active';
    entry.tokens = [...entry.tokens, { token, write: true }];
    state[userId] = entry;
    saveState(state);

    console.log('token that allows reading and writing chat again:', token);
  } catch (error) {
    const status = error instanceof Error && 'status' in error ? error.status : undefined;
    console.error(`Unmuting the fan failed: ${error}${status ? ` Additional information: ${status}` : ''}`);
  }
}
```

Add this to `server.js`. It sets the fan's tracked status back to active and issues a fresh read-and-write token. Calling this on a banned fan is a deliberate decision to let them back in. It doesn't happen as a side effect of anything else in this tutorial.

## Wire up the commands

Route the command line to the four functions above.

```javascript
const [, , command, userIdArgument] = process.argv;
const targetUserId = userIdArgument ?? 'fan-42';

if (command === 'grant') {
  await grantChatAccess(targetUserId);
} else if (command === 'mute') {
  await muteFan(targetUserId);
} else if (command === 'unmute') {
  await unmuteFan(targetUserId);
} else if (command === 'ban') {
  await banFan(targetUserId);
} else {
  console.log('usage: node server.js <grant|mute|unmute|ban> [userId]');
}
```

Add this to the end of `server.js`, after every function it calls. `process.argv[2]` is the command you typed, `grant`, `mute`, `unmute`, or `ban`, and `process.argv[3]`, if you passed one, is the User ID to act on. This is what actually reads `node server.js grant fan-42` in [Run it](#run-it) and calls `grantChatAccess('fan-42')`, rather than the command line being decorative text next to code that always does the same thing.

## Check what a token allows

Decode a token to see exactly which permissions it carries.

```javascript
const parsed = fanClient.parseToken('replace-with-the-token-to-inspect');

if (parsed) {
  console.log('token expires in', parsed.ttl, 'minutes');
  console.log('channel permissions:', parsed.resources?.channels);
}
```

Run this from `fan.js`, or from any script holding the token, against any token you printed earlier in this tutorial. `parseToken` needs no secret key, so support tooling can use it too. This is the step to reach for when a fan can seemingly still do something you thought you'd taken away. It shows the permissions PubNub stored on the token, rather than the permissions you intended to grant it.

## Run it

Run each step from `server.js` in order, applying the printed token to `fan.js` between steps.

```bash
node server.js grant fan-42
```

Copy the printed token, then run:

```bash
node fan.js <token>
```

`fan.js` connects and its publish succeeds. Leave it running, or stop it and continue. Next, mute the fan:

```bash
node server.js mute fan-42
```

This revokes the token you just applied. Copy the new, read-only token, stop `fan.js`, and restart it:

```bash
node fan.js <mutedToken>
```

`fan.js` connects, and this time its publish attempt logs an access-denied error from its own `catch` block instead of succeeding. Finally, with `fan.js` still running on the muted token, ban the fan from a new terminal:

```bash
node server.js ban fan-42
```

Shortly after, `fan.js`'s status listener logs an access-denied status too, without you restarting it, once the revoke propagates. To let the fan back in, run:

```bash
node server.js unmute fan-42
```

and apply the newly printed token the same way. Press **Ctrl+C** in either terminal to stop it.

## What happened

1. `server.js` granted `fan-42` a token with `read` and `write` on `game.chat`, bound to that User ID, and recorded it as an outstanding writable token.
2. `fan.js` registered its status listener, applied the token, subscribed, and published successfully, since the token allowed it.
3. `server.js` revoked that token, then issued a second, narrower one for the same fan, with `write` left out, and recorded the new token in place of the old.
4. `fan.js` applied the narrower token, and its next publish attempt failed in its own `try`/`catch`, reported directly at the call site.
5. `server.js` revoked every token this fan held, including the muted one still applied in `fan.js`, and `fan.js`'s next request failed too, reported through the same status listener, without a restart.
6. `server.js` granted a fresh read-and-write token, restoring what a ban or mute had removed.

## Next steps

* [Build match-day chat for fans](https://www.pubnub.com/docs/use-cases/sports-media-entertainment/real-time-chat.md). Build the chat this tutorial's fan connects to.
* [Hide and remove chat messages during a match](https://www.pubnub.com/docs/use-cases/sports-media-entertainment/game-chat-moderation.md). Act on one message instead of on a fan's whole access.
* [Access Manager](https://www.pubnub.com/docs/security/access-control/overview.md). The token model, TTL bounds, and revocation constraints behind every call on this page.
* [Permission model](https://www.pubnub.com/docs/security/access-control/permission-model.md). Every permission flag a token can carry, beyond `read` and `write`.

Last updated at: 2026-09-30T07:20:08.000Z
