DEV Community

Cover image for Slack Bot Token vs User Token Scopes: Least Privilege in Practice
SapotaCorp
SapotaCorp

Posted on • Originally published at sapotacorp.vn

Slack Bot Token vs User Token Scopes: Least Privilege in Practice

The fastest way to get a Slack app rejected from the Marketplace is to request scopes you cannot justify, and the fastest way to ask for scopes you cannot justify is to misunderstand the difference between a bot token and a user token. Getting your slack bot token scopes right from the manifest stage saves you a reinstall loop, an awkward conversation with a workspace admin, and a review-team back-and-forth that can stall a launch for weeks. This is the model I use on every Slack app I ship, plus the audit I run before any security review.

Two tokens, two permission models

A Slack app can hold two kinds of OAuth token, and they are not interchangeable.

The bot token (xoxb-...) is attached to the app's bot user. When the app posts a message, uploads a file, or adds a reaction, the action shows up under the bot's name. Bot tokens are the default for every modern app built on Bolt or the Slack CLI, and their scopes look like chat:write and channels:read. The important constraint: a bot only sees channels it has been invited to, and it only sees DMs that were sent directly to the bot.

The user token (xoxp-...) acts on behalf of a person who granted access. You use it when you need to do something only a human can do: search the whole workspace, read a private DM between two other people, or post a message that appears as if the user typed it. User scopes look like chat:write:user and search:read, and every one of them requires the user to click Allow in an OAuth flow.

An app can request both. The OAuth response issues both tokens, each with its own independent scope list. Treat them as two separate permission sets that happen to live in the same install.

When you actually need a user token

Default to the bot token. It is more durable because it does not depend on any individual staying in the workspace, and the modern tooling is bot-first end to end. I only reach for a user token in four situations:

  1. You need search.messages. Searching the workspace is a user-only capability. There is no bot scope that grants it, full stop.
  2. The message must appear as the user, not the bot. For example, sending a templated reply on a person's behalf where it would be misleading for it to come from a bot.
  3. Compliance export of DMs or private channels that the bot was never invited to.
  4. Sign in with Slack via OpenID Connect, which uses the identity.basic, identity.email, and identity.team scopes.

If your use case is not on that list, a bot token can almost certainly do the job, and you should prove that before adding a single user scope.

Concrete scope examples

Here are the bot scopes that cover the bulk of real apps, mapped to the API methods that need them:

Scope

Grants

API method

chat:write

Post a message as the bot

chat.postMessage, chat.postEphemeral

chat:write.public

Post to a public channel the bot has not joined

chat.postMessage

channels:read

Read public channel metadata

conversations.list, conversations.info

channels:history

Read public channel messages

conversations.history, conversations.replies

groups:history

Read messages in a private channel the bot was invited to

conversations.history

users:read

List users and read basic profiles

users.list, users.info

users:read.email

Read user email addresses

users.info (returns email)

files:write

Upload, delete, and share files

files.upload, files.delete

reactions:write

Add and remove reactions

reactions.add, reactions.remove

commands

Receive slash command payloads

Slash command endpoint

app_mentions:read

Receive the app_mention event

Events API

And the user scopes worth knowing, with the reason each one exists:

Scope

Grants

Why you would need it

search:read

Search the whole workspace

A bot token simply cannot search

chat:write:user

Post as the user

The message has to read as if the user wrote it

im:history (user)

Read the user's DMs

Compliance export and data archive

groups:history (user)

Read private channels the user belongs to

Audit and export

identity.basic

Read basic identity

Sign in with Slack

One trap worth calling out: chat:write (bot) and chat:write:user (user) are two distinct scopes with two distinct behaviors, not aliases for each other. Posting with the wrong token fails with not_authed or posts under the wrong identity. The same applies when you migrate a classic app to granular scopes. The old all-in-one bot scope was equivalent to fifteen-plus modern scopes, so never assume a new scope inherits the permissions of an old one.

A war story: the bot that could not read DMs

A Vietnamese commercial bank wanted a compliance bot that scanned the DMs of fifty compliance officers and flagged any message containing card numbers or national ID numbers. The bot did not need to post DMs, only read them.

The tempting wrong answer was to give the bot im:history, im:read, and chat:write. The problem is that a bot's im:history only covers DMs sent to the bot itself. It cannot see a DM between officer A and officer B, which is exactly what the bot was supposed to scan. The bot would have installed cleanly and scanned nothing.

The correct design used user token scopes for the read side and a tiny bot token for the write side. Each of the fifty officers ran their own OAuth grant, and the app stored one user token per officer carrying im:history, groups:history, and mpim:history. The bot token kept only chat:write so it could post an alert into a #compliance channel when it found a leak.

oauth_config:
  scopes:
    bot:
      - chat:write
      - channels:read
    user:
      - im:history
      - groups:history
      - mpim:history
Enter fullscreen mode Exit fullscreen mode

That design passed audit cleanly because the bot held no power to read anyone's DMs on its own, and every officer had personally consented to having their own messages scanned. The permission boundary matched the consent boundary, which is the whole point.

Least privilege as a working rule

Principle of least privilege (PoLP) means the app gets the minimum permission needed to run its stated features, and nothing held back "just in case." Slack enforces this through granular scopes, and the Marketplace review team rejects over-permissioned apps as a matter of process. Four rules turn the principle into practice:

Start from empty. A new manifest has zero scopes. Every time a call returns missing_scope, read the needed field in the error and add exactly that scope. Never add scopes from intuition, because intuition over-grants.

Pick the narrow scope. Need to read one public channel? Use channels:history, not groups:history plus im:history plus mpim:history to "cover everything." Need to list users? Use users:read, and skip users:read.email unless you genuinely send email.

Bot first, user second. User tokens are expensive: the person has to grant them, they revoke when the person leaves, and audit logs attribute their activity to a human rather than the bot, which causes confusion during investigations.

Document every scope. Keep a SCOPES.md in the repo that lists each scope, the feature it powers, the API method, and a one-line justification. When review time comes, you read a file instead of grepping a codebase under pressure.

Running a scope audit before review

I run the same audit quarterly on internal apps and always before a Marketplace submission. Walking through it on a real internal app at the same bank, a Bolt app that reported suspicious transactions to a risk team:

List what is granted. Pull the live scope list from the App Management page at api.slack.com/apps/<app_id>/oauth and paste it into SCOPES.md with a [REVIEW] tag next to anything you are not sure about.

Cross-check the code. grep -r "users.profile" src/ revealed that the feature which once needed users:read.email had moved to a different email provider a quarter earlier, so the scope was dead. grep -r "files.upload" src/ returned nothing, which meant files:write was redundant too.

Confirm with Audit Logs. On Enterprise Grid, the Audit Logs API (audit/v1/logs) lets you count how often the app actually exercised each scope. A scope with zero calls in ninety days is unused, and that is the evidence you remove it on.

Reduce and reinstall. Edit the manifest to drop the dead scopes, save, and the app page raises a banner telling you a reinstall is required. The token issued after reinstall carries only the scopes you kept.

Log it for compliance. Commit the updated SCOPES.md noting what was removed and when, so the compliance team can archive it against a framework like ISO 27001.

Two pitfalls bite teams here. The first is auditing only when prompted, which lets dead scopes pile up for years so that a leaked token has far more reach than it should. Schedule the review on a hard calendar and record "no change needed" when that is the outcome. The second is reinstalling without telling anyone. The Slack banner is easy to miss, and a silent reinstall means new methods start failing missing_scope while everyone assumes the old token is fine. Tell the workspace admin the reinstall window before you save the manifest.

One last gotcha when auditing an older app: if the manifest shows a single bot line, that is the classic all-in-one scope, not a one-permission app. It is equivalent to fifteen-plus granular scopes. Convert it to granular scopes first, then audit, or you will badly underestimate what the app can actually do.

Takeaway

Bot tokens and user tokens solve different problems, and least privilege is the discipline of granting only what each feature provably needs. Start from an empty scope list, add scopes only when the API demands them, prefer bot over user and narrow over broad, and keep a SCOPES.md that turns your next review into a five-minute read. Do that and the Marketplace review or the internal security sign-off stops being a scramble and becomes a formality.

If your team is building a Slack app and wants the scope model right before review instead of after a rejection, reach out to us or see how we help teams ship and secure Slack integrations.

Top comments (0)