Skip to content

Diagnostic Slack

the scope was granted to the user token, not the bot

You added the scope. The consent screen listed it, an admin approved it, you reinstalled, you replaced the stored token, and the call still returns missing_scope with that exact scope in needed. Open the app configuration and there it is, plainly granted — under User Token Scopes, while every line of your code authenticates with the xoxb- bot token.

Read-only token Python and Node.js Tests included
Grey shopping cart
Photo by Bruno Kelzer on Unsplash
The short answer

Call auth.test once with each of the app's two tokens and read X-OAuth-Scopes off both responses. Those two headers are the two scope lists, as granted, on the credentials you are actually deploying. The scope you need is on one of them and your code is calling with the other.

Then decide which identity should do the work rather than which list is easier to edit. Some scopes are only offered on one side: search:read and users.profile:write exist only as user scopes, app_mentions:read and commands only as bot scopes. Where both are possible, prefer the bot — it outlives the person who installed the app.

The problem in plain words

Slack apps have two identities and most codebases have one environment variable. The install flow returns both credentials in one response: access_token is the bot token, authed_user.access_token is the user token, and they carry two entirely separate grants that were approved on the same consent screen in the same click. Nothing in the response labels which one your code should keep, and a great many apps keep the first one, call it SLACK_TOKEN, and never learn there was a second.

The configuration page reinforces the confusion by putting both lists on one screen under one heading. Bot Token Scopes and User Token Scopes sit a few pixels apart, they autocomplete from overlapping vocabularies, and adding channels:history to the wrong one looks identical to adding it to the right one. The app then requests it, the admin approves it, and the grant lands on a token your runtime never touches.

What makes this specifically maddening is that the standard repair for missing_scope — add the scope, reinstall, replace the token — is exactly what you have been doing, and it is working perfectly. Each reinstall faithfully re-grants the scope to the token that already had it. The error is unchanged because nothing about the failing call has changed, and after the third round trip through an admin approval queue it is very easy to conclude that Slack is broken.

Scope added inthe configunder User TokenScopesAdmin approvesthe installone consentscreen, two listsGrant lands onxoxpbot list unchangedCode calls withxoxbone env var, twotokensmissing_scopeagainthe reinstallchanged nothingadd the scope again, reinstall again
Every step here succeeds. The scope was requested, approved and granted, and it landed on the credential the code never uses.

Why it happens

The two lists are independent grants, not a display detail. A bot token's scopes describe what the app may do as itself; a user token's scopes describe what it may do while impersonating the human who installed it. They are stored separately, revoked separately, and reported separately in X-OAuth-Scopes.

Some scopes only exist on one side. Message search is user-only: there is no bot equivalent of search:read, so an app that wants to search must hold a user token, full stop. Equally, commands and app_mentions:read are bot-only. When the scope you need is one of these, "move it to the other list" is not available and the code has to change instead.

auth.test distinguishes them and the token prefix does not always. A bot token's response carries a bot_id; a user token's does not. Both return a user_id starting U or W, and on a bot token that id is the bot user, which is why comparing ids tells you nothing.

The user token dies with the user. A scope moved to the user side to make one call work has quietly made the whole integration dependent on one employee's account remaining active. That is a different failure with its own note, and it arrives on somebody's last day.

One environment variable cannot hold two credentials. The durable fix is not a scope edit, it is naming: SLACK_BOT_TOKEN and SLACK_USER_TOKEN, so every call site states which identity it is acting as, and a swap is visible in a diff rather than at runtime.

The fix, as a flow

The script reads two scope lists off two responses and compares them against each other, which is the one thing the app configuration page cannot do for you. The page shows what the app requests. The header shows what the running credential was given.

Both X-OAuth-Scopes headersread from live responsesHeld by the calling tokennothing to do hereHeld by the other tokenmove it, or switch the callOnly offered on one listthe code has to changeHeld by neitheran ordinary missing scopeHeld by bothchoose an identity deliberately
The advice splits on whether the scope is offered on both lists. Telling somebody to move search:read to the bot list costs them an afternoon looking for a list entry that does not exist.

How to fix it

Put both tokens in the environment under distinct names

The audit needs both halves of the install: SLACK_BOT_TOKEN from Bot User OAuth Token and SLACK_USER_TOKEN from User OAuth Token on the same page. If your store only kept one, that is itself the finding — the script says so and reports what it can see from one side.

Ask each token who it is

One auth.test per token. The presence of bot_id in the body is the only reliable discriminator, and the script checks it against the variable's name: a user token in SLACK_BOT_TOKEN explains a great deal on its own.

Read both scope lists off those same two responses

X-OAuth-Scopes is returned on every Web API response and describes the calling token. Reading it from the live response, rather than from the configuration page, is the point: the page describes the app you intend to deploy, the header describes the credential that is running.

Diff the two lists before you look for anything specific

The scopes held by one token and not the other are where every instance of this bug lives. Printing that split first often ends the investigation before the developer has finished naming the scope they were looking for.

Name the scope you need and the token your code calls with

--need channels:history --caller bot asks the only question that matters: is the grant on the credential the failing code path uses? The answer separates "on the other token" from "granted nowhere", which is an ordinary missing scope and a different repair.

Move the scope, or move the call, and reinstall

If the app should act as itself, add the scope under Bot Token Scopes and reinstall. If it must act as a human, keep it under User Token Scopes and change the code to authenticate with authed_user.access_token. Either way the token in the store must be replaced; a reinstall does not upgrade tokens already in circulation.

How to check it worked

Re-run with the same arguments after the reinstall. The scope should be reported on the calling token, and the split should show it as no longer exclusive to the other side.

python3 slack_token_identity_split.py --need channels:history --caller bot
# correct    channels:history  held by the bot token this code path calls with
# 1 scope(s) checked, 0 on the wrong token

The full code

Two GETs, one per token, and nothing else — this script is handed both of an app's credentials at once, which is exactly why it must not be able to act with either. Four pure functions carry the logic: scope_set parses the header, identity_kind reads bot_id, split does the three-way comparison, and verdict answers the question for one named scope.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 20 Slack fixes, free and open source.
slack_token_identity_split.py
"""Compare a Slack app's two tokens and find scopes granted to the wrong one.

Read only. Two GET requests and nothing else. This script holds both halves of
an app's install at once, so it must not be able to act with either; the repair
is printed for a human to run.
"""
import argparse
import logging
import os
import sys

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_token_identity_split")

API = "https://slack.com/api/"

# Scopes Slack offers on one list only. When the scope you need is one of these,
# "move it to the other list" is not an available repair and the calling code has
# to change instead. Not exhaustive; it is the set that shows up in this bug.
USER_ONLY = {
    "search:read", "search:read.files", "search:read.messages",
    "users.profile:write", "identity.basic", "identity.email",
    "identity.avatar", "identity.team", "stars:read", "dnd:write:user",
}
BOT_ONLY = {
    "app_mentions:read", "commands", "incoming-webhook", "workflow.steps:execute",
}


def scope_set(header):
    """X-OAuth-Scopes as a frozenset. Pure.

    Slack returns one comma-joined string on every response. Some proxies strip
    it, so an absent header means "unknown" and must never be read as "none" --
    that reading turns a missing header into a full sheet of false findings.
    """
    if not header:
        return frozenset()
    return frozenset(s.strip() for s in header.split(",") if s.strip())


def identity_kind(body):
    """Which of the two token classes answered auth.test. Pure.

    A bot token's response carries bot_id; a user token's does not. Both carry a
    user_id beginning U or W, and on a bot token that id is the bot user, which
    is why the id alone cannot tell them apart.
    """
    if body.get("ok") is not True:
        return "unusable"
    return "bot" if body.get("bot_id") else "user"


def split(bot_scopes, user_scopes):
    """The three-way comparison this whole audit rests on. Pure.

    Returns (both, bot_only, user_only). The last two are where every instance
    of this bug lives, and printing them usually ends the investigation.
    """
    return (tuple(sorted(bot_scopes & user_scopes)),
            tuple(sorted(bot_scopes - user_scopes)),
            tuple(sorted(user_scopes - bot_scopes)))


def side(scope):
    """Which of the two lists this scope can appear on at all. Pure."""
    if scope in USER_ONLY:
        return "user-only"
    if scope in BOT_ONLY:
        return "bot-only"
    return "either"


def verdict(scope, caller, caller_scopes, other_scopes):
    """Answer one question: is this scope on the token the failing code uses?

    `caller` is "bot" or "user" -- the identity the runtime code path
    authenticates as. Pure, so the whole truth table runs offline.
    """
    other = "user" if caller == "bot" else "bot"
    where = side(scope)
    held = scope in caller_scopes
    elsewhere = scope in other_scopes

    if held and elsewhere:
        return ("granted-twice",
                "both tokens hold %s. Nothing is broken, but the call site "
                "decides which identity acts, so make that choice explicit "
                "rather than leaving it to whichever variable was in scope."
                % scope)
    if held:
        return ("correct",
                "held by the %s token this code path calls with" % caller)
    if elsewhere:
        if where != "either":
            return ("wrong-side",
                    "%s is granted to the %s token, and it is a %s scope: it "
                    "cannot be moved. The %s code path has to authenticate "
                    "with the %s token instead." % (scope, other, where, caller, other))
        return ("wrong-side",
                "%s is granted to the %s token and this code path calls with "
                "the %s token. Reinstalling re-grants it to the same side, "
                "which is why the error never changed." % (scope, other, caller))
    if where != "either" and where != caller + "-only":
        return ("unobtainable",
                "%s is a %s scope and this code path calls with the %s token. "
                "It is not offered on the %s list, so request it on the %s "
                "side and switch the call." % (scope, where, caller, caller, other))
    return ("granted-nowhere",
            "neither token holds %s. This is an ordinary missing scope: add it "
            "to the %s list, reinstall, and replace the stored token." % (scope, caller))


def probe(session, token):
    """auth.test for one token. Returns (X-OAuth-Scopes, parsed body)."""
    r = session.get(API + "auth.test", headers={"Authorization": "Bearer " + token},
                    timeout=30)
    try:
        return r.headers.get("X-OAuth-Scopes"), r.json()
    except ValueError:
        return r.headers.get("X-OAuth-Scopes"), {"ok": False, "error": "unparseable_body"}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--need", action="append", default=[],
                    help="a scope the failing call requires; repeatable")
    ap.add_argument("--caller", choices=("bot", "user"), default="bot",
                    help="which token the failing code path authenticates with")
    args = ap.parse_args()

    tokens = {"bot": os.environ.get("SLACK_BOT_TOKEN"),
              "user": os.environ.get("SLACK_USER_TOKEN")}
    if not tokens["bot"] and not tokens["user"]:
        log.error("set SLACK_BOT_TOKEN and SLACK_USER_TOKEN to the two halves of "
                  "one install (OAuth & Permissions shows both)")
        return 2

    s = requests.Session()
    scopes = {"bot": frozenset(), "user": frozenset()}
    bad = 0

    for role in ("bot", "user"):
        token = tokens[role]
        if not token:
            log.warning("%-16s %s", "token-absent",
                        "SLACK_%s_TOKEN is unset, so that side of the grant "
                        "cannot be read and findings about it are provisional"
                        % role.upper())
            bad += 1
            continue
        header, body = probe(s, token)
        kind = identity_kind(body)
        scopes[role] = scope_set(header)
        if kind == "unusable":
            log.warning("%-16s %s token: auth.test answered ok: false, error=%s",
                        "unusable", role, body.get("error") or "<no error field>")
            bad += 1
            continue
        if kind != role:
            bad += 1
            log.warning("%-16s SLACK_%s_TOKEN holds a %s token: auth.test %s a "
                        "bot_id. Half of this bug is a mislabelled variable.",
                        "mislabelled", role.upper(), kind,
                        "returned" if kind == "bot" else "returned no")
        log.info("%-16s %s token, team=%s, %d scope(s)%s", "identity", kind,
                 body.get("team_id"), len(scopes[role]),
                 "" if scopes[role] else " (X-OAuth-Scopes absent from the response)")

    both, bot_only, user_only = split(scopes["bot"], scopes["user"])
    log.info("%-16s %s", "on both", ", ".join(both) or "<none>")
    log.info("%-16s %s", "bot only", ", ".join(bot_only) or "<none>")
    log.info("%-16s %s", "user only", ", ".join(user_only) or "<none>")

    other = "user" if args.caller == "bot" else "bot"
    for scope in args.need:
        state, detail = verdict(scope, args.caller, scopes[args.caller], scopes[other])
        line = "%-16s %-22s %s" % (state, scope, detail)
        if state in ("correct", "granted-twice"):
            log.info(line)
            continue
        bad += 1
        log.warning(line)
        if state == "wrong-side":
            log.warning("  repair: decide which identity should act, then either move "
                        "the scope between the two lists and reinstall, or call with "
                        "the token that already holds it")
        else:
            log.warning("  repair: OAuth & Permissions -> %s Token Scopes, add the "
                        "scope, reinstall, replace the stored token",
                        "Bot" if args.caller == "bot" else "User")

    log.info("%d scope(s) checked, %d on the wrong token or unreadable",
             len(args.need), bad)
    return 1 if bad else 0


if __name__ == "__main__":
    sys.exit(main())
slack-token-identity-split.mjs
/**
 * Compare a Slack app's two tokens and find scopes granted to the wrong one.
 *
 * Read only. Two GET requests and nothing else. This script holds both halves
 * of an app's install at once, so it must not be able to act with either; the
 * repair is printed for a human to run.
 */
const API = 'https://slack.com/api/';

// Scopes Slack offers on one list only. When the scope you need is one of
// these, "move it to the other list" is not an available repair and the calling
// code has to change instead. Not exhaustive; it is the set that shows up here.
export const USER_ONLY = new Set([
  'search:read', 'search:read.files', 'search:read.messages',
  'users.profile:write', 'identity.basic', 'identity.email',
  'identity.avatar', 'identity.team', 'stars:read', 'dnd:write:user',
]);
export const BOT_ONLY = new Set([
  'app_mentions:read', 'commands', 'incoming-webhook', 'workflow.steps:execute',
]);

/**
 * X-OAuth-Scopes as a Set. Pure. An absent header means "unknown" and must
 * never be read as "none": that reading turns a stripped header into a full
 * sheet of false findings.
 */
export function scopeSet(header) {
  if (!header) return new Set();
  return new Set(header.split(',').map((s) => s.trim()).filter(Boolean));
}

/**
 * Which of the two token classes answered auth.test. Pure. A bot token's
 * response carries bot_id; a user token's does not. Both carry a user_id, and
 * on a bot token that id is the bot user, so the id alone proves nothing.
 */
export function identityKind(body) {
  if (body?.ok !== true) return 'unusable';
  return body.bot_id ? 'bot' : 'user';
}

/**
 * The three-way comparison this whole audit rests on. Pure.
 * Returns [both, botOnly, userOnly].
 */
export function split(botScopes, userScopes) {
  const both = [...botScopes].filter((s) => userScopes.has(s)).sort();
  const botOnly = [...botScopes].filter((s) => !userScopes.has(s)).sort();
  const userOnly = [...userScopes].filter((s) => !botScopes.has(s)).sort();
  return [both, botOnly, userOnly];
}

/** Which of the two lists this scope can appear on at all. Pure. */
export function side(scope) {
  if (USER_ONLY.has(scope)) return 'user-only';
  if (BOT_ONLY.has(scope)) return 'bot-only';
  return 'either';
}

/**
 * Answer one question: is this scope on the token the failing code uses?
 * `caller` is "bot" or "user". Pure, so the truth table runs offline.
 */
export function verdict(scope, caller, callerScopes, otherScopes) {
  const other = caller === 'bot' ? 'user' : 'bot';
  const where = side(scope);
  const held = callerScopes.has(scope);
  const elsewhere = otherScopes.has(scope);

  if (held && elsewhere) {
    return ['granted-twice',
      `both tokens hold ${scope}. Nothing is broken, but the call site decides ` +
      'which identity acts, so make that choice explicit rather than leaving it ' +
      'to whichever variable was in scope.'];
  }
  if (held) {
    return ['correct', `held by the ${caller} token this code path calls with`];
  }
  if (elsewhere) {
    if (where !== 'either') {
      return ['wrong-side',
        `${scope} is granted to the ${other} token, and it is a ${where} scope: ` +
        `it cannot be moved. The ${caller} code path has to authenticate with ` +
        `the ${other} token instead.`];
    }
    return ['wrong-side',
      `${scope} is granted to the ${other} token and this code path calls with ` +
      `the ${caller} token. Reinstalling re-grants it to the same side, which is ` +
      'why the error never changed.'];
  }
  if (where !== 'either' && where !== `${caller}-only`) {
    return ['unobtainable',
      `${scope} is a ${where} scope and this code path calls with the ${caller} ` +
      `token. It is not offered on the ${caller} list, so request it on the ` +
      `${other} side and switch the call.`];
  }
  return ['granted-nowhere',
    `neither token holds ${scope}. This is an ordinary missing scope: add it to ` +
    `the ${caller} list, reinstall, and replace the stored token.`];
}

async function probe(token) {
  const res = await fetch(API + 'auth.test', {
    headers: { Authorization: `Bearer ${token}` },
  });
  const header = res.headers.get('x-oauth-scopes');
  try {
    return [header, await res.json()];
  } catch {
    return [header, { ok: false, error: 'unparseable_body' }];
  }
}

async function main() {
  const argv = process.argv.slice(2);
  const need = [];
  let caller = 'bot';
  for (let i = 0; i < argv.length; i += 1) {
    if (argv[i] === '--need') need.push(argv[i + 1]);
    if (argv[i] === '--caller') caller = argv[i + 1] === 'user' ? 'user' : 'bot';
  }

  const tokens = { bot: process.env.SLACK_BOT_TOKEN, user: process.env.SLACK_USER_TOKEN };
  if (!tokens.bot && !tokens.user) {
    console.error('set SLACK_BOT_TOKEN and SLACK_USER_TOKEN to the two halves of ' +
                  'one install (OAuth & Permissions shows both)');
    process.exitCode = 2;
    return;
  }

  const scopes = { bot: new Set(), user: new Set() };
  let bad = 0;

  for (const role of ['bot', 'user']) {
    const token = tokens[role];
    if (!token) {
      console.warn(`${'token-absent'.padEnd(16)} SLACK_${role.toUpperCase()}_TOKEN is ` +
        'unset, so that side of the grant cannot be read and findings about it ' +
        'are provisional');
      bad += 1;
      continue;
    }
    const [header, body] = await probe(token);
    const kind = identityKind(body);
    scopes[role] = scopeSet(header);
    if (kind === 'unusable') {
      console.warn(`${'unusable'.padEnd(16)} ${role} token: auth.test answered ` +
        `ok: false, error=${body?.error ?? '<no error field>'}`);
      bad += 1;
      continue;
    }
    if (kind !== role) {
      bad += 1;
      console.warn(`${'mislabelled'.padEnd(16)} SLACK_${role.toUpperCase()}_TOKEN holds ` +
        `a ${kind} token: auth.test ${kind === 'bot' ? 'returned' : 'returned no'} a ` +
        'bot_id. Half of this bug is a mislabelled variable.');
    }
    console.log(`${'identity'.padEnd(16)} ${kind} token, team=${body.team_id}, ` +
      `${scopes[role].size} scope(s)` +
      (scopes[role].size ? '' : ' (X-OAuth-Scopes absent from the response)'));
  }

  const [both, botOnly, userOnly] = split(scopes.bot, scopes.user);
  console.log(`${'on both'.padEnd(16)} ${both.join(', ') || '<none>'}`);
  console.log(`${'bot only'.padEnd(16)} ${botOnly.join(', ') || '<none>'}`);
  console.log(`${'user only'.padEnd(16)} ${userOnly.join(', ') || '<none>'}`);

  const other = caller === 'bot' ? 'user' : 'bot';
  for (const scope of need) {
    const [state, detail] = verdict(scope, caller, scopes[caller], scopes[other]);
    const line = `${state.padEnd(16)} ${String(scope).padEnd(22)} ${detail}`;
    if (state === 'correct' || state === 'granted-twice') {
      console.log(line);
      continue;
    }
    bad += 1;
    console.warn(line);
    if (state === 'wrong-side') {
      console.warn('  repair: decide which identity should act, then either move the ' +
        'scope between the two lists and reinstall, or call with the token that ' +
        'already holds it');
    } else {
      console.warn('  repair: OAuth & Permissions -> ' +
        `${caller === 'bot' ? 'Bot' : 'User'} Token Scopes, add the scope, ` +
        'reinstall, replace the stored token');
    }
  }

  console.log(`${need.length} scope(s) checked, ${bad} on the wrong token or unreadable`);
  process.exitCode = bad ? 1 : 0;
}

// Only run when invoked directly, so importing this module in the tests does not
// execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The case worth pinning is the one that decides whether the advice is even possible: a user-only scope like search:read, wanted by a code path that calls with the bot token. Every other finding ends in "move it and reinstall", and this one must not — there is no bot list to move it to, and telling somebody to look for one costs them an afternoon.

test_slack_token_identity_split.py
from slack_token_identity_split import identity_kind, scope_set, side, split, verdict


def test_scope_header_absent_is_unknown_not_empty():
    assert scope_set(None) == frozenset()
    assert scope_set("channels:read, users:read ,") == frozenset(
        {"channels:read", "users:read"})


def test_bot_id_is_the_only_discriminator():
    assert identity_kind({"ok": True, "user_id": "U1", "bot_id": "B1"}) == "bot"
    assert identity_kind({"ok": True, "user_id": "U1"}) == "user"
    assert identity_kind({"ok": False, "error": "invalid_auth"}) == "unusable"


def test_split_reports_the_exclusive_halves():
    both, bot_only, user_only = split(frozenset({"a", "b"}), frozenset({"b", "c"}))
    assert (both, bot_only, user_only) == (("b",), ("a",), ("c",))


def test_scope_on_the_other_token_is_the_finding():
    state, detail = verdict("channels:history", "bot",
                            frozenset(), frozenset({"channels:history"}))
    assert state == "wrong-side"
    assert "never changed" in detail


def test_user_only_scope_cannot_be_moved_to_the_bot_list():
    assert side("search:read") == "user-only"
    state, detail = verdict("search:read", "bot", frozenset(), frozenset())
    assert state == "unobtainable"
    assert "not offered on the bot list" in detail


def test_user_only_scope_held_by_the_user_token_says_switch_the_call():
    state, detail = verdict("search:read", "bot", frozenset(),
                            frozenset({"search:read"}))
    assert state == "wrong-side"
    assert "cannot be moved" in detail


def test_scope_on_the_calling_token_is_not_reported():
    assert verdict("users:read", "bot", frozenset({"users:read"}), frozenset())[0] == "correct"


def test_scope_on_neither_token_is_an_ordinary_missing_scope():
    state, detail = verdict("users:read", "user", frozenset(), frozenset())
    assert state == "granted-nowhere"
    assert "add it to the user list" in detail


def test_both_tokens_holding_it_is_ambiguity_rather_than_a_fault():
    assert verdict("users:read", "bot", frozenset({"users:read"}),
                   frozenset({"users:read"}))[0] == "granted-twice"
slack-token-identity-split.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { identityKind, scopeSet, side, split, verdict } from './slack-token-identity-split.mjs';

test('scope header absent is unknown not empty', () => {
  assert.equal(scopeSet(null).size, 0);
  assert.deepEqual([...scopeSet('channels:read, users:read ,')].sort(),
    ['channels:read', 'users:read']);
});

test('bot_id is the only discriminator', () => {
  assert.equal(identityKind({ ok: true, user_id: 'U1', bot_id: 'B1' }), 'bot');
  assert.equal(identityKind({ ok: true, user_id: 'U1' }), 'user');
  assert.equal(identityKind({ ok: false, error: 'invalid_auth' }), 'unusable');
});

test('split reports the exclusive halves', () => {
  const [both, botOnly, userOnly] = split(new Set(['a', 'b']), new Set(['b', 'c']));
  assert.deepEqual([both, botOnly, userOnly], [['b'], ['a'], ['c']]);
});

test('scope on the other token is the finding', () => {
  const [state, detail] = verdict('channels:history', 'bot',
    new Set(), new Set(['channels:history']));
  assert.equal(state, 'wrong-side');
  assert.match(detail, /never changed/);
});

test('user only scope cannot be moved to the bot list', () => {
  assert.equal(side('search:read'), 'user-only');
  const [state, detail] = verdict('search:read', 'bot', new Set(), new Set());
  assert.equal(state, 'unobtainable');
  assert.match(detail, /not offered on the bot list/);
});

test('user only scope held by the user token says switch the call', () => {
  const [state, detail] = verdict('search:read', 'bot', new Set(), new Set(['search:read']));
  assert.equal(state, 'wrong-side');
  assert.match(detail, /cannot be moved/);
});

test('scope on the calling token is not reported', () => {
  assert.equal(verdict('users:read', 'bot', new Set(['users:read']), new Set())[0], 'correct');
});

test('scope on neither token is an ordinary missing scope', () => {
  const [state, detail] = verdict('users:read', 'user', new Set(), new Set());
  assert.equal(state, 'granted-nowhere');
  assert.match(detail, /add it to the user list/);
});

test('both tokens holding it is ambiguity rather than a fault', () => {
  assert.equal(verdict('users:read', 'bot', new Set(['users:read']),
    new Set(['users:read']))[0], 'granted-twice');
});

FAQ

How do I tell a bot token from a user token if the prefix is missing?

Call auth.test and look for bot_id in the body. A bot token returns it, a user token does not. The xoxb- and xoxp- prefixes are reliable for classic tokens, but a rotated token arrives as xoxe.xoxb- or xoxe.xoxp- and a token pasted into the wrong variable keeps its own prefix regardless of what the variable is called, so the body is the honest answer.

Can I just add the scope to both lists?

You can, and for scopes offered on both sides it works. It also doubles the consent screen and leaves the choice of identity implicit at every call site, which is the condition that produced the bug. Decide whether the app is acting as itself or as a person, grant the scope on that side only, and let the second token fail loudly if something calls it.

Why does search have no bot scope?

Slack scopes search to a human's view of the workspace. search:read returns what that person can see, including their DMs and private channels, so there is no coherent bot equivalent and none is offered. An app that must search has to hold a user token, which also means inheriting that user's account lifecycle.

Does reinstalling ever move a scope from one list to the other?

No. A reinstall re-grants whatever the app configuration currently requests, on the side it requests it. If the scope is on the user list, every reinstall grants it to the user token, which is exactly why repeating the standard missing_scope repair changes nothing here.

What should I store after an install that grants both?

Both tokens, under distinct keys, alongside the team and enterprise ids. access_token is the bot credential and authed_user.access_token is the user one. A single SLACK_TOKEN variable cannot represent two identities, and the day someone needs the other one they will overwrite the first.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.