Diagnostic Slack
missing_scope tells you the scope needed and the ones you have
{"ok": false, "error": "missing_scope", "needed": "channels:history", "provided": "chat:write,commands,users:read"}. The developer swears the scope is in the app configuration, and it is — but the app was never reinstalled, so the token in production still carries the grant it was issued with.
Two probes, and Slack does most of the work. Read the X-OAuth-Scopes response header, which Slack returns on every Web API response with the calling token's complete current scope list. Then call each read method the app depends on and, on failure, read body.needed and body.provided.
needed is an OR list, not an AND list: any one of the scopes it names satisfies the call. And the fix is never just "add the scope" — a token is a frozen snapshot of the grant at install time, so the app has to be reinstalled and the stored token replaced.
The problem in plain words
This is the one place Slack is unusually generous. Most APIs tell you that you are not allowed to do something. Slack tells you exactly which scope would have allowed it, exactly which scopes you hold, and repeats the second list in a header on every response you have ever received. Almost nobody reads any of it, because the whole thing arrives inside a 200 OK.
The half that costs real time is the install. Editing the scope list in the app configuration changes what will be requested at the next installation; it does not upgrade tokens that are already in circulation. So the config is right, the code is right, the error persists, and the missing step is one nobody wrote down: reinstall, then copy the new token into the deployment.
For a distributed app the same fact is much larger. Every existing installation keeps its old grant until each workspace re-authorizes, so a new scope means a re-consent campaign, not a deploy.
Why it happens
A token is a snapshot, not a pointer. The scopes attached to a token are fixed at the moment it is issued. Nothing you change in the app configuration reaches backwards into tokens already issued, which is why "I added the scope" and "the token has the scope" are unrelated statements.
needed is an OR list. Slack often names several scopes that would each satisfy the call — channels:history or groups:history, say, depending on the conversation type. Adding all of them because they appeared in one error message is how a routine integration ends up over-scoped.
Bot scopes and user scopes are separate lists. They are granted on the same consent screen and stored in the same OAuth response, so a scope added to User Token Scopes while the code authenticates with the xoxb- bot token produces missing_scope with the scope visibly present in the app configuration.
Not every refusal is a scope refusal. not_allowed_token_type means the method wants a different class of token entirely; invalid_auth and token_revoked mean the credential is wrong or dead. Adding scopes and reinstalling changes none of those, and a scope audit that lumps them in sends people to the wrong screen.
Removing a scope also needs a reinstall. Pruning an over-broad grant is the same operation in reverse: the live token keeps everything it was issued with until the app is installed again.
The fix, as a flow
The script reads X-OAuth-Scopes off the same response it judges, so the granted list and the refusal always describe one token. Comparing a cached list against a live call is how the wrong scope gets added.
How to fix it
Read X-OAuth-Scopes off any response
Slack returns the calling token's full granted scope list in the X-OAuth-Scopes response header on every Web API call, successful or not. One auth.test gives you the complete inventory without guessing.
Probe the read methods the app actually depends on
Call each one with harmless arguments — limit=1 is plenty — and look at body.error. This is empirical rather than theoretical: it tells you what this token can do today, not what the documentation says it should be able to do.
Separate scope failures from credential failures
missing_scope is a permission gap. invalid_auth, token_revoked, account_inactive and not_allowed_token_type are not, and no amount of scope editing fixes them. Report them differently or the fix goes to the wrong place.
Read needed as a choice, not a shopping list
Pick the narrowest scope in needed that covers the conversations you actually touch. If your app only reads public channels, channels:history alone is the answer even when groups:history and im:history are offered alongside it.
Add the scope, then reinstall, then replace the token
OAuth & Permissions → Bot Token Scopes, then reinstall to the workspace, then copy the new xoxb- token into the deployment. A manifest-managed app edits oauth_config.scopes.bot and deploys the manifest first. Skipping the third step leaves the old token in production and the error unchanged.
How to check it worked
Re-run after reinstalling. Every probed method should report ok, and the granted list printed at the top should now contain the scope you added.
python3 slack_scope_audit.py
# granted: 6 scope(s) on this token
# 6 method(s) probed, 0 blocked by a missing scope
The full code
One GET per probed method, and the interesting part of each response is a header. Two pure functions: the header parser, because a scope list arrives as one comma-joined string with inconsistent spacing, and the verdict, which has to keep four kinds of refusal apart — a genuine scope gap, a credential problem wearing a scope error's clothes, a failure that has nothing to do with permissions, and a granted list that disagrees with the response it came from.
"""Audit which Slack read methods this token's scopes actually allow.
Read only. GET requests and nothing else: give this the bot token you deploy, so
the answer is about the credential in production. The repair is printed, never
performed, because this token can post into your workspace.
"""
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_scope_audit")
API = "https://slack.com/api/"
# Cheap read probes. Each one is refused by a different scope, so the set doubles
# as a map of what the token can reach.
PROBES = [
("auth.test", {}),
("conversations.list", {"limit": "1", "types": "public_channel"}),
("users.list", {"limit": "1"}),
("emoji.list", {}),
("usergroups.list", {}),
("team.info", {}),
]
# Refusals that are about the credential rather than the grant. Adding a scope
# and reinstalling does nothing for any of these.
CREDENTIAL_ERRORS = {
"invalid_auth", "not_authed", "token_revoked", "token_expired",
"account_inactive", "not_allowed_token_type",
}
def parse_scopes(header):
"""Split an X-OAuth-Scopes header into a sorted tuple. Pure.
Slack sends one comma-joined string, and the header is absent from some
proxied responses, so treat missing as "unknown" rather than "none".
"""
if not header:
return ()
return tuple(sorted({s.strip() for s in header.split(",") if s.strip()}))
def verdict(granted, body):
"""Classify one probed method against a granted scope list. Pure.
`granted` is what X-OAuth-Scopes reported; `body` is the parsed response.
"""
if body.get("ok") is True:
return ("ok", "allowed by the %d scope(s) this token holds" % len(granted))
error = body.get("error") or "<no error field>"
if error in CREDENTIAL_ERRORS:
return ("wrong-token",
"error=%s. This is the credential, not the grant: adding a scope "
"and reinstalling will not change it." % error)
if error != "missing_scope":
return ("other",
"error=%s, which is not a permission problem. Fix it before "
"concluding anything about scopes." % error)
needed = [s.strip() for s in (body.get("needed") or "").split(",") if s.strip()]
if not needed:
return ("missing-scope",
"missing_scope, and the response did not name one. Read the "
"method reference for its scope list.")
already = [s for s in needed if s in granted]
if already:
return ("scope-list-mismatch",
"missing_scope while the granted list already contains %s. The "
"list and the token are not the same token: read X-OAuth-Scopes "
"off this very response." % ", ".join(already))
return ("missing-scope",
"add any one of: %s. needed is an OR list, so one suffices, and the "
"app must be reinstalled before the token carries it."
% ", ".join(needed))
def probe(session, method, params):
r = session.get(API + method, params=params, timeout=30)
return r.headers.get("X-OAuth-Scopes"), r.json()
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--method", action="append", default=[],
help="probe this read method as well as the default set; repeatable")
args = ap.parse_args()
token = os.environ.get("SLACK_BOT_TOKEN")
if not token:
log.error("set SLACK_BOT_TOKEN (use the token the app actually deploys with)")
return 2
s = requests.Session()
s.headers.update({"Authorization": "Bearer " + token})
probes = PROBES + [(m, {}) for m in args.method]
blocked = 0
for method, params in probes:
header, body = probe(s, method, params)
granted = parse_scopes(header)
state, detail = verdict(granted, body)
if method == probes[0][0]:
log.info("granted: %d scope(s) on this token: %s",
len(granted), ", ".join(granted) or "<header absent>")
line = "%-19s %-20s %s" % (state, method, detail)
if state == "ok":
log.info(line)
continue
blocked += 1
log.warning(line)
if state == "missing-scope":
log.warning(" provided=%s", body.get("provided") or "?")
log.warning(" repair: OAuth & Permissions -> Bot Token Scopes, add the "
"scope, reinstall the app, replace the stored token")
log.info("%d method(s) probed, %d refused", len(probes), blocked)
return 1 if blocked else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Audit which Slack read methods this token's scopes actually allow.
*
* Read only. GET requests and nothing else: give this the bot token you deploy,
* so the answer is about the credential in production. The repair is printed,
* never performed.
*/
const API = 'https://slack.com/api/';
// Cheap read probes. Each one is refused by a different scope, so the set
// doubles as a map of what the token can reach.
const PROBES = [
['auth.test', {}],
['conversations.list', { limit: '1', types: 'public_channel' }],
['users.list', { limit: '1' }],
['emoji.list', {}],
['usergroups.list', {}],
['team.info', {}],
];
// Refusals that are about the credential rather than the grant. Adding a scope
// and reinstalling does nothing for any of these.
const CREDENTIAL_ERRORS = new Set([
'invalid_auth', 'not_authed', 'token_revoked', 'token_expired',
'account_inactive', 'not_allowed_token_type',
]);
/**
* Split an X-OAuth-Scopes header into a sorted array. Pure.
* The header is absent from some proxied responses, so missing means unknown.
*/
export function parseScopes(header) {
if (!header) return [];
const set = new Set(header.split(',').map((s) => s.trim()).filter(Boolean));
return [...set].sort();
}
/**
* Classify one probed method against a granted scope list. Pure.
*/
export function verdict(granted, body) {
if (body.ok === true) {
return ['ok', `allowed by the ${granted.length} scope(s) this token holds`];
}
const error = body.error ?? '<no error field>';
if (CREDENTIAL_ERRORS.has(error)) {
return ['wrong-token',
`error=${error}. This is the credential, not the grant: adding a scope and ` +
'reinstalling will not change it.'];
}
if (error !== 'missing_scope') {
return ['other',
`error=${error}, which is not a permission problem. Fix it before ` +
'concluding anything about scopes.'];
}
const needed = (body.needed ?? '').split(',').map((s) => s.trim()).filter(Boolean);
if (needed.length === 0) {
return ['missing-scope',
'missing_scope, and the response did not name one. Read the method ' +
'reference for its scope list.'];
}
const already = needed.filter((s) => granted.includes(s));
if (already.length) {
return ['scope-list-mismatch',
`missing_scope while the granted list already contains ${already.join(', ')}. ` +
'The list and the token are not the same token: read X-OAuth-Scopes off ' +
'this very response.'];
}
return ['missing-scope',
`add any one of: ${needed.join(', ')}. needed is an OR list, so one suffices, ` +
'and the app must be reinstalled before the token carries it.'];
}
async function probe(token, method, params) {
const url = new URL(API + method);
for (const [k, v] of Object.entries(params)) url.searchParams.set(k, v);
const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
return { header: res.headers.get('x-oauth-scopes'), body: await res.json() };
}
async function main() {
const token = process.env.SLACK_BOT_TOKEN;
if (!token) {
console.error('set SLACK_BOT_TOKEN (use the token the app actually deploys with)');
process.exitCode = 2;
return;
}
const extra = process.argv.slice(2).filter((a) => !a.startsWith('-')).map((m) => [m, {}]);
const probes = [...PROBES, ...extra];
let blocked = 0;
for (const [method, params] of probes) {
const { header, body } = await probe(token, method, params);
const granted = parseScopes(header);
const [state, detail] = verdict(granted, body);
if (method === probes[0][0]) {
console.log(`granted: ${granted.length} scope(s) on this token: ` +
`${granted.join(', ') || '<header absent>'}`);
}
const line = `${state.padEnd(19)} ${method.padEnd(20)} ${detail}`;
if (state === 'ok') { console.log(line); continue; }
blocked += 1;
console.warn(line);
if (state === 'missing-scope') {
console.warn(` provided=${body.provided ?? '?'}`);
console.warn(' repair: OAuth & Permissions -> Bot Token Scopes, add the scope, ' +
'reinstall the app, replace the stored token');
}
}
console.log(`${probes.length} method(s) probed, ${blocked} refused`);
process.exitCode = blocked ? 1 : 0;
}
// Only run when invoked directly, so importing this module from the test file
// does not execute main() and fail the suite 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 tests hold two lines that are easy to blur. needed is an OR list, so the advice has to be “add one of these” rather than “add these”; and a credential error is not a scope error, because sending someone to the scopes screen for an invalid_auth wastes an afternoon and a reinstall.
from slack_scope_audit import parse_scopes, verdict
def test_scope_header_is_split_and_trimmed():
assert parse_scopes("channels:read, users:read ,chat:write") == (
"channels:read", "chat:write", "users:read")
def test_absent_scope_header_is_empty_not_a_crash():
assert parse_scopes(None) == ()
assert parse_scopes("") == ()
def test_a_successful_call_needs_nothing():
state, _ = verdict(("channels:read",), {"ok": True})
assert state == "ok"
def test_missing_scope_names_the_alternatives_as_a_choice():
body = {"ok": False, "error": "missing_scope",
"needed": "channels:history,groups:history",
"provided": "chat:write,users:read"}
state, detail = verdict(("chat:write", "users:read"), body)
assert state == "missing-scope"
assert "any one of" in detail
assert "channels:history" in detail
assert "reinstalled" in detail
def test_credential_errors_are_not_scope_errors():
state, detail = verdict((), {"ok": False, "error": "not_allowed_token_type"})
assert state == "wrong-token"
assert "will not change it" in detail
def test_unrelated_errors_do_not_become_scope_findings():
state, _ = verdict(("channels:read",), {"ok": False, "error": "channel_not_found"})
assert state == "other"
def test_missing_scope_without_a_needed_field_still_reports():
state, detail = verdict((), {"ok": False, "error": "missing_scope"})
assert state == "missing-scope"
assert "did not name one" in detail
def test_a_granted_list_that_contradicts_the_response_is_its_own_state():
body = {"ok": False, "error": "missing_scope", "needed": "channels:history"}
state, detail = verdict(("channels:history",), body)
assert state == "scope-list-mismatch"
assert "X-OAuth-Scopes" in detail
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseScopes, verdict } from './slack-scope-audit.mjs';
test('scope header is split and trimmed', () => {
assert.deepEqual(parseScopes('channels:read, users:read ,chat:write'),
['channels:read', 'chat:write', 'users:read']);
});
test('absent scope header is empty not a crash', () => {
assert.deepEqual(parseScopes(null), []);
assert.deepEqual(parseScopes(''), []);
});
test('a successful call needs nothing', () => {
assert.equal(verdict(['channels:read'], { ok: true })[0], 'ok');
});
test('missing_scope names the alternatives as a choice', () => {
const body = {
ok: false, error: 'missing_scope',
needed: 'channels:history,groups:history',
provided: 'chat:write,users:read',
};
const [state, detail] = verdict(['chat:write', 'users:read'], body);
assert.equal(state, 'missing-scope');
assert.match(detail, /any one of/);
assert.match(detail, /channels:history/);
assert.match(detail, /reinstalled/);
});
test('credential errors are not scope errors', () => {
const [state, detail] = verdict([], { ok: false, error: 'not_allowed_token_type' });
assert.equal(state, 'wrong-token');
assert.match(detail, /will not change it/);
});
test('unrelated errors do not become scope findings', () => {
assert.equal(verdict(['channels:read'], { ok: false, error: 'channel_not_found' })[0],
'other');
});
test('missing_scope without a needed field still reports', () => {
const [state, detail] = verdict([], { ok: false, error: 'missing_scope' });
assert.equal(state, 'missing-scope');
assert.match(detail, /did not name one/);
});
test('a granted list that contradicts the response is its own state', () => {
const body = { ok: false, error: 'missing_scope', needed: 'channels:history' };
const [state, detail] = verdict(['channels:history'], body);
assert.equal(state, 'scope-list-mismatch');
assert.match(detail, /X-OAuth-Scopes/);
});
FAQ
I added the scope and still get missing_scope. Why?
Because the token was issued before you added it. Scopes are frozen into a token at install time, and editing the app configuration only changes what will be requested at the next installation. Reinstall the app to the workspace and replace the stored token with the new one.
What is the difference between needed and provided?
needed is the set of scopes that would have satisfied this call, as an OR list: any one of them is enough. provided is what the calling token currently holds. The same information as provided is on every response in the X-OAuth-Scopes header, including successful ones.
Should I add every scope listed in needed?
No. Pick the narrowest one that covers the conversations you actually touch. needed often lists the public, private, DM and group-DM variants of the same capability, and adding all four is how an integration that reads one channel ends up with the whole workspace archive.
The scope is in the app configuration but the call still fails. What else could it be?
Check which list it is in. Bot Token Scopes attach to the xoxb- token and User Token Scopes attach to the xoxp- token, and a scope granted to one does nothing for the other. Also check the error itself: not_allowed_token_type and invalid_auth are credential problems, not scope problems.
Do I have to reinstall to remove a scope too?
Yes. Pruning is the same operation in reverse. The live token keeps everything it was issued with until the app is installed again, so a token that leaked before the prune still carries the old, wider grant.
Related field notes
- Slack answers 200 and hides the failure in the body
- not_in_channel: the bot was never invited
- next_cursor ignored, so one page is all you see
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.
- Permission scopes — Slack Docs
- Installing with OAuth — Slack Docs
- Token types — Slack Docs
- Using the Slack Web API — Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.