Diagnostic Slack
invalid_auth: the xapp- token is in the Web API slot
{"ok": false, "error": "invalid_auth"} on a call that plainly should work, with a token copied out of the app configuration ten minutes ago. The token is not expired, not revoked, and not missing a scope. It starts with xapp-, and the Web API has never accepted one of those.
Slack issues at least six classes of credential and tells them apart by prefix: xoxb- bot, xoxp- user, xapp- app-level, xoxe. rotating, xoxe- refresh, xwfp- workflow, plus xoxc- browser session tokens that are not a supported credential at all. Each is accepted by a different surface, and using one on the wrong surface produces invalid_auth rather than anything that names the mistake.
So check the prefix, not the call. The script below reads every Slack credential in the environment, decides what class each one is, and compares that against what the variable it lives in is for. That finding needs no network at all. It then calls auth.test on the credentials that should be Web API tokens, which separates "wrong class" from "right class, mangled value" — a trailing newline from $(cat secret) gives the identical error.
The problem in plain words
The app configuration page shows several tokens. Basic Information has App-Level Tokens; OAuth & Permissions has the Bot User OAuth Token and, if user scopes were requested, a user token. They are all long opaque strings that begin with xox-something, they all look like the thing you were told to copy, and the pages do not sit next to each other. Copying the wrong one is not carelessness; it is the default outcome of a page layout.
What turns a thirty-second mistake into an afternoon is the error. invalid_auth is what Slack says for a revoked token, a truncated token, a token from a different workspace, and a token of a class this endpoint has never accepted. All four send you looking in different places, and the message distinguishes none of them. Developers reasonably assume the credential is stale, reinstall the app, get a new token, put it in the same wrong variable, and get the same error.
The related trap is the browser session token. Search results and older tooling suggest lifting an xoxc- value out of the Slack web client's local storage, and it works — for a while, from the right IP, alongside a d cookie. It is not a supported credential, it dies without notice, and it authenticates as a human being with all of that human's access.
Why it happens
The prefix is the class, and it is public. There is no ambiguity to resolve: xoxb- is a bot token, xapp- is an app-level token, xoxe- is a refresh token and not an access token. A check that reads the first eight characters catches the whole family of swaps without a request, which means it can run at process startup rather than at 3am.
App-level tokens serve a different API surface. An xapp- token opens a Socket Mode connection and reads app event authorizations. It is not a workspace credential, holds no workspace scopes, and cannot call chat.postMessage or conversations.list no matter what it is granted. It is not a weaker bot token; it is a different thing.
A refresh token is not an access token. With rotation on, the install flow hands back two secrets and only one of them is a bearer credential. Storing xoxe-1-... in the variable the Web API client reads produces invalid_auth forever, and it looks like a rotation bug because rotation is the reason there are two strings.
Whitespace produces the same error as the wrong token. A secret read with $(cat /run/secrets/slack) keeps its trailing newline, a value pasted into a YAML file keeps its quotes, and Slack rejects both as invalid_auth. Checking the prefix without checking hygiene finds three of the four cases and leaves the most annoying one.
Name the variable for the role. SLACK_TOKEN is the root cause of this note. Two variables named SLACK_BOT_TOKEN and SLACK_APP_TOKEN, validated by prefix at startup, make the swap impossible to deploy rather than merely possible to find.
The fix, as a flow
The whole finding is available before a packet leaves the machine: a prefix names the class, and the variable it sits in names the role. The one call afterwards exists to separate a wrong class from a right class with a newline stuck on the end.
How to fix it
List the slots, not the tokens
Write down the environment variables the app reads and what each is for: a Web API credential, a Socket Mode credential, a manifest credential. The audit is a comparison between the role of the slot and the class of the value in it, so the roles have to be stated before anything can be checked.
Classify each value by prefix
Longest prefix first, because xoxe.xoxb- and xoxe- both begin the same way and mean different things — the first is a rotating bot access token, the second is the refresh token that mints it. Getting that order wrong reports a healthy rotating app as broken.
Check hygiene before class
A value with leading or trailing whitespace, or wrapped in quotes that were meant to be shell syntax, will fail with the same invalid_auth as a wrong class. Report it first and separately, because the repair is a deployment fix rather than a credential fix.
Confirm the Web API slots with auth.test
For the values whose class fits their slot, one auth.test each. It needs no scopes, and it separates the two remaining cases: a right-class credential that authenticates, and a right-class credential that does not — which is a revoked, rotated or copied-from-another-workspace token, not a swap.
Do not call auth.test with the app-level token expecting an identity
It will fail, and that failure is not evidence of anything wrong. An xapp- token has no workspace identity to report. The script says so explicitly rather than counting it as a finding, because an audit that flags a correctly configured Socket Mode credential will be turned off within a week.
Validate the prefix at startup and keep the names honest
Assert the prefix of each credential when the process boots and exit loudly if it does not match. Take the bot token from OAuth & Permissions and the app-level token from Basic Information, and never collapse them into one variable because "the app only needs one token today".
How to check it worked
Re-run after fixing the slots. Every configured credential should report as fitting its role, and the Web API ones should authenticate.
python3 slack_token_class_check.py
# fits SLACK_BOT_TOKEN bot token in a Web API slot, authenticates as B0123
# fits SLACK_APP_TOKEN app-level token in the Socket Mode slot
# 2 slot(s) checked, 0 holding the wrong class of credential
The full code
The interesting half of this script does no I/O. classify maps a prefix to a token class and slot_verdict decides whether that class belongs in the slot it was found in — both pure, both able to answer before a request is sent. The single GET is auth.test, and it exists only to separate a wrong class from a right class with a mangled value.
"""Check that every Slack credential in the environment is in the right slot.
Read only, and mostly offline: the class of a token is in its prefix, so the
finding is available before any request. One auth.test per Web API credential
confirms it. Nothing is written, and no secret value is ever printed.
"""
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_class_check")
API = "https://slack.com/api/"
# Longest first: xoxe.xoxb- is a rotating access token and xoxe- is the refresh
# token that mints one. Matching the short prefix first calls a healthy rotating
# app broken.
PREFIXES = (
("xoxe.xoxb-", "rotating-bot",
"a rotating bot token; a Web API credential with a 12 hour life"),
("xoxe.xoxp-", "rotating-user",
"a rotating user token, or an app configuration token; the prefix is shared"),
("xoxe-", "refresh",
"a refresh token. It is redeemed for an access token and is not one"),
("xoxb-", "bot", "a bot token; the Web API credential for an app acting as itself"),
("xoxp-", "user", "a user token; the Web API credential for an app acting as a person"),
("xapp-", "app-level",
"an app-level token; Socket Mode and app event methods, no workspace identity"),
("xoxc-", "browser-session",
"a session token from the Slack web client. Not a supported credential"),
("xwfp-", "workflow", "a workflow token, minted per step and alive for 15 minutes"),
("xoxa-", "legacy-workspace", "a legacy workspace token, long retired"),
)
WEB_API_CLASSES = {"bot", "user", "rotating-bot", "rotating-user"}
# What each role will accept. A slot is a promise about what belongs in it.
ROLE_ACCEPTS = {
"web-api": WEB_API_CLASSES,
"socket-mode": {"app-level"},
"manifest": {"rotating-user"},
}
DEFAULT_SLOTS = (
("SLACK_BOT_TOKEN", "web-api"),
("SLACK_USER_TOKEN", "web-api"),
("SLACK_APP_TOKEN", "socket-mode"),
("SLACK_CONFIG_TOKEN", "manifest"),
)
def classify(token):
"""Prefix to token class. Pure, and never returns the token itself."""
text = str(token or "")
for prefix, name, note in PREFIXES:
if text.startswith(prefix):
return (name, note)
if text.startswith("xox"):
return ("unknown-slack", "an unrecognised xox prefix; Slack adds classes over time")
return ("not-a-slack-token", "no Slack token prefix at all")
def slot_verdict(name, role, raw):
"""Does the value in this environment variable belong in this slot? Pure.
`raw` is the value exactly as the environment holds it, whitespace included,
because whitespace is one of the findings. Returns (state, detail).
"""
if raw is None:
return ("unset", "not set. If the app needs a %s credential it will fail "
"at first use." % role)
if raw == "":
return ("empty", "set to the empty string, which is worse than unset: the "
"usual `if not token` guard never fires.")
if raw != raw.strip():
return ("whitespace-in-value",
"the value has leading or trailing whitespace. A secret read with "
"$(cat ...) keeps its newline and Slack answers invalid_auth, "
"which reads exactly like the wrong token.")
if raw[0] in "'\"" or raw[-1] in "'\"":
return ("quoted-value",
"the value is wrapped in quote characters. Those are shell or YAML "
"syntax that was stored literally, and Slack sees a token that "
"starts with a quote.")
cls, note = classify(raw)
accepts = ROLE_ACCEPTS.get(role, set())
if cls in accepts:
return ("fits", "%s in a %s slot: %s" % (cls, role, note))
if cls == "browser-session":
return ("browser-session-token",
"%s holds %s. It authenticates as the human whose browser it came "
"from, expires without warning, and is not supported." % (name, note))
if cls == "refresh":
return ("refresh-token-in-access-slot",
"%s holds %s. Rotation hands back two strings and only the other "
"one is a bearer credential." % (name, note))
if cls == "app-level" and role == "web-api":
return ("app-level-in-web-slot",
"%s is a Web API slot and holds an app-level token. The Web API "
"has never accepted one; every call will answer invalid_auth." % name)
if cls in WEB_API_CLASSES and role == "socket-mode":
return ("web-token-in-socket-slot",
"%s is the Socket Mode slot and holds %s. Socket Mode needs an "
"app-level token from Basic Information." % (name, note))
if cls == "not-a-slack-token":
return ("not-a-slack-token",
"%s does not look like a Slack credential at all. Check what the "
"deployment actually injected here." % name)
return ("wrong-class",
"%s expects a %s credential and holds %s" % (name, role, note))
def auth_test(session, token):
r = session.get(API + "auth.test", headers={"Authorization": "Bearer " + token},
timeout=30)
try:
return r.json()
except ValueError:
return {"ok": False, "error": "unparseable_body"}
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--slot", action="append", default=[], metavar="NAME=ROLE",
help="check this variable as well, e.g. SLACK_TOKEN=web-api; repeatable")
args = ap.parse_args()
slots = list(DEFAULT_SLOTS)
for spec in args.slot:
name, _, role = spec.partition("=")
slots.append((name, role or "web-api"))
s = requests.Session()
bad = 0
for name, role in slots:
raw = os.environ.get(name)
state, detail = slot_verdict(name, role, raw)
if state == "unset":
log.info("%-28s %-20s %s", state, name, detail)
continue
if state != "fits":
bad += 1
log.warning("%-28s %-20s %s", state, name, detail)
log.warning(" repair: bot token from OAuth & Permissions, app-level "
"token from Basic Information, one variable each")
continue
cls, _ = classify(raw)
if cls not in WEB_API_CLASSES:
# An app-level token has no workspace identity to report, so calling
# auth.test with it proves nothing and flagging it would be noise.
log.info("%-28s %-20s %s", state, name, detail)
continue
body = auth_test(s, raw)
if body.get("ok") is True:
log.info("%-28s %-20s %s, authenticates as %s in %s", state, name, detail,
body.get("bot_id") or body.get("user_id"), body.get("team_id"))
continue
bad += 1
log.warning("%-28s %-20s the class is right and the value is not: error=%s",
"class-right-value-wrong", name, body.get("error") or "?")
log.warning(" repair: this is a revoked, rotated or foreign-workspace "
"token, not a swapped one. Reissue it rather than moving it")
log.info("%d slot(s) checked, %d holding the wrong class of credential",
len(slots), bad)
return 1 if bad else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Check that every Slack credential in the environment is in the right slot.
*
* Read only, and mostly offline: the class of a token is in its prefix, so the
* finding is available before any request. One auth.test per Web API credential
* confirms it. Nothing is written, and no secret value is ever printed.
*/
const API = 'https://slack.com/api/';
// Longest first: xoxe.xoxb- is a rotating access token and xoxe- is the refresh
// token that mints one. Matching the short prefix first calls a healthy rotating
// app broken.
const PREFIXES = [
['xoxe.xoxb-', 'rotating-bot',
'a rotating bot token; a Web API credential with a 12 hour life'],
['xoxe.xoxp-', 'rotating-user',
'a rotating user token, or an app configuration token; the prefix is shared'],
['xoxe-', 'refresh',
'a refresh token. It is redeemed for an access token and is not one'],
['xoxb-', 'bot', 'a bot token; the Web API credential for an app acting as itself'],
['xoxp-', 'user', 'a user token; the Web API credential for an app acting as a person'],
['xapp-', 'app-level',
'an app-level token; Socket Mode and app event methods, no workspace identity'],
['xoxc-', 'browser-session',
'a session token from the Slack web client. Not a supported credential'],
['xwfp-', 'workflow', 'a workflow token, minted per step and alive for 15 minutes'],
['xoxa-', 'legacy-workspace', 'a legacy workspace token, long retired'],
];
const WEB_API_CLASSES = new Set(['bot', 'user', 'rotating-bot', 'rotating-user']);
// What each role will accept. A slot is a promise about what belongs in it.
const ROLE_ACCEPTS = {
'web-api': WEB_API_CLASSES,
'socket-mode': new Set(['app-level']),
manifest: new Set(['rotating-user']),
};
const DEFAULT_SLOTS = [
['SLACK_BOT_TOKEN', 'web-api'],
['SLACK_USER_TOKEN', 'web-api'],
['SLACK_APP_TOKEN', 'socket-mode'],
['SLACK_CONFIG_TOKEN', 'manifest'],
];
/** Prefix to token class. Pure, and never returns the token itself. */
export function classify(token) {
const text = String(token ?? '');
for (const [prefix, name, note] of PREFIXES) {
if (text.startsWith(prefix)) return [name, note];
}
if (text.startsWith('xox')) {
return ['unknown-slack', 'an unrecognised xox prefix; Slack adds classes over time'];
}
return ['not-a-slack-token', 'no Slack token prefix at all'];
}
/**
* Does the value in this environment variable belong in this slot? Pure.
* `raw` is the value exactly as the environment holds it, whitespace included,
* because whitespace is one of the findings.
*/
export function slotVerdict(name, role, raw) {
if (raw === undefined || raw === null) {
return ['unset',
`not set. If the app needs a ${role} credential it will fail at first use.`];
}
if (raw === '') {
return ['empty',
'set to the empty string, which is worse than unset: the usual falsy guard ' +
'never fires.'];
}
if (raw !== raw.trim()) {
return ['whitespace-in-value',
'the value has leading or trailing whitespace. A secret read with $(cat ...) ' +
'keeps its newline and Slack answers invalid_auth, which reads exactly like ' +
'the wrong token.'];
}
if ('\'"'.includes(raw[0]) || '\'"'.includes(raw[raw.length - 1])) {
return ['quoted-value',
'the value is wrapped in quote characters. Those are shell or YAML syntax ' +
'that was stored literally, and Slack sees a token that starts with a quote.'];
}
const [cls, note] = classify(raw);
const accepts = ROLE_ACCEPTS[role] ?? new Set();
if (accepts.has(cls)) return ['fits', `${cls} in a ${role} slot: ${note}`];
if (cls === 'browser-session') {
return ['browser-session-token',
`${name} holds ${note}. It authenticates as the human whose browser it came ` +
'from, expires without warning, and is not supported.'];
}
if (cls === 'refresh') {
return ['refresh-token-in-access-slot',
`${name} holds ${note}. Rotation hands back two strings and only the other ` +
'one is a bearer credential.'];
}
if (cls === 'app-level' && role === 'web-api') {
return ['app-level-in-web-slot',
`${name} is a Web API slot and holds an app-level token. The Web API has ` +
'never accepted one; every call will answer invalid_auth.'];
}
if (WEB_API_CLASSES.has(cls) && role === 'socket-mode') {
return ['web-token-in-socket-slot',
`${name} is the Socket Mode slot and holds ${note}. Socket Mode needs an ` +
'app-level token from Basic Information.'];
}
if (cls === 'not-a-slack-token') {
return ['not-a-slack-token',
`${name} does not look like a Slack credential at all. Check what the ` +
'deployment actually injected here.'];
}
return ['wrong-class', `${name} expects a ${role} credential and holds ${note}`];
}
async function authTest(token) {
const res = await fetch(API + 'auth.test', {
headers: { Authorization: `Bearer ${token}` },
});
try {
return await res.json();
} catch {
return { ok: false, error: 'unparseable_body' };
}
}
async function main() {
const slots = [...DEFAULT_SLOTS];
const args = process.argv.slice(2);
for (let i = 0; i < args.length; i += 1) {
if (args[i] !== '--slot') continue;
const [name, role] = String(args[i + 1] ?? '').split('=');
if (name) slots.push([name, role || 'web-api']);
}
let bad = 0;
for (const [name, role] of slots) {
const raw = process.env[name];
const [state, detail] = slotVerdict(name, role, raw);
if (state === 'unset') {
console.log(`${state.padEnd(28)} ${name.padEnd(20)} ${detail}`);
continue;
}
if (state !== 'fits') {
bad += 1;
console.warn(`${state.padEnd(28)} ${name.padEnd(20)} ${detail}`);
console.warn(' repair: bot token from OAuth & Permissions, app-level token ' +
'from Basic Information, one variable each');
continue;
}
const [cls] = classify(raw);
if (!WEB_API_CLASSES.has(cls)) {
// An app-level token has no workspace identity to report, so calling
// auth.test with it proves nothing and flagging it would be noise.
console.log(`${state.padEnd(28)} ${name.padEnd(20)} ${detail}`);
continue;
}
const body = await authTest(raw);
if (body?.ok === true) {
console.log(`${state.padEnd(28)} ${name.padEnd(20)} ${detail}, authenticates ` +
`as ${body.bot_id ?? body.user_id} in ${body.team_id}`);
continue;
}
bad += 1;
console.warn(`${'class-right-value-wrong'.padEnd(28)} ${name.padEnd(20)} the ` +
`class is right and the value is not: error=${body?.error ?? '?'}`);
console.warn(' repair: this is a revoked, rotated or foreign-workspace token, ' +
'not a swapped one. Reissue it rather than moving it');
}
console.log(`${slots.length} slot(s) checked, ${bad} holding the wrong class of ` +
'credential');
process.exitCode = bad ? 1 : 0;
}
// Only run when invoked directly, so importing this module in the tests does not
// execute main() and start reading the environment.
if (import.meta.url === `file://${process.argv[1]}`) {
main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}
Add a test
Two things have to be pinned. The prefix table must be ordered so xoxe.xoxb- is matched before xoxe-, or every rotating app is reported as storing a refresh token in the access slot. And the correctly configured Socket Mode credential must come back clean, because an audit that flags a working xapp- token is an audit nobody runs twice.
from slack_token_class_check import classify, slot_verdict
def test_app_level_token_in_a_web_api_slot_is_the_headline_finding():
state, detail = slot_verdict("SLACK_BOT_TOKEN", "web-api", "xapp-1-A01-99-abc")
assert state == "app-level-in-web-slot"
assert "invalid_auth" in detail
def test_app_level_token_in_its_own_slot_is_not_a_finding():
state, _ = slot_verdict("SLACK_APP_TOKEN", "socket-mode", "xapp-1-A01-99-abc")
assert state == "fits"
def test_bot_token_in_the_socket_slot_is_the_swap_the_other_way():
state, _ = slot_verdict("SLACK_APP_TOKEN", "socket-mode", "xoxb-1-abc")
assert state == "web-token-in-socket-slot"
def test_rotating_access_token_is_not_mistaken_for_a_refresh_token():
assert classify("xoxe.xoxb-1-abc")[0] == "rotating-bot"
assert slot_verdict("SLACK_BOT_TOKEN", "web-api", "xoxe.xoxb-1-abc")[0] == "fits"
def test_refresh_token_in_the_access_slot_is_named_as_such():
state, detail = slot_verdict("SLACK_BOT_TOKEN", "web-api", "xoxe-1-abc")
assert state == "refresh-token-in-access-slot"
assert "bearer credential" in detail
def test_browser_session_token_is_reported_even_though_it_might_work():
state, detail = slot_verdict("SLACK_BOT_TOKEN", "web-api", "xoxc-1-abc")
assert state == "browser-session-token"
assert "not supported" in detail
def test_trailing_newline_is_caught_before_the_class_check():
state, _ = slot_verdict("SLACK_BOT_TOKEN", "web-api", "xoxb-1-abc\n")
assert state == "whitespace-in-value"
def test_a_quoted_value_is_its_own_finding():
assert slot_verdict("SLACK_BOT_TOKEN", "web-api", '"xoxb-1-abc"')[0] == "quoted-value"
def test_empty_string_is_distinguished_from_unset():
assert slot_verdict("SLACK_BOT_TOKEN", "web-api", None)[0] == "unset"
assert slot_verdict("SLACK_BOT_TOKEN", "web-api", "")[0] == "empty"
def test_a_value_that_is_not_a_slack_token_at_all():
assert classify("ghp_abc")[0] == "not-a-slack-token"
assert slot_verdict("SLACK_BOT_TOKEN", "web-api", "ghp_abc")[0] == "not-a-slack-token"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { classify, slotVerdict } from './slack-token-class-check.mjs';
test('app level token in a web api slot is the headline finding', () => {
const [state, detail] = slotVerdict('SLACK_BOT_TOKEN', 'web-api', 'xapp-1-A01-99-abc');
assert.equal(state, 'app-level-in-web-slot');
assert.match(detail, /invalid_auth/);
});
test('app level token in its own slot is not a finding', () => {
const [state] = slotVerdict('SLACK_APP_TOKEN', 'socket-mode', 'xapp-1-A01-99-abc');
assert.equal(state, 'fits');
});
test('bot token in the socket slot is the swap the other way', () => {
const [state] = slotVerdict('SLACK_APP_TOKEN', 'socket-mode', 'xoxb-1-abc');
assert.equal(state, 'web-token-in-socket-slot');
});
test('rotating access token is not mistaken for a refresh token', () => {
assert.equal(classify('xoxe.xoxb-1-abc')[0], 'rotating-bot');
assert.equal(slotVerdict('SLACK_BOT_TOKEN', 'web-api', 'xoxe.xoxb-1-abc')[0], 'fits');
});
test('refresh token in the access slot is named as such', () => {
const [state, detail] = slotVerdict('SLACK_BOT_TOKEN', 'web-api', 'xoxe-1-abc');
assert.equal(state, 'refresh-token-in-access-slot');
assert.match(detail, /bearer credential/);
});
test('browser session token is reported even though it might work', () => {
const [state, detail] = slotVerdict('SLACK_BOT_TOKEN', 'web-api', 'xoxc-1-abc');
assert.equal(state, 'browser-session-token');
assert.match(detail, /not supported/);
});
test('trailing newline is caught before the class check', () => {
const [state] = slotVerdict('SLACK_BOT_TOKEN', 'web-api', 'xoxb-1-abc\n');
assert.equal(state, 'whitespace-in-value');
});
test('a quoted value is its own finding', () => {
const [state] = slotVerdict('SLACK_BOT_TOKEN', 'web-api', '"xoxb-1-abc"');
assert.equal(state, 'quoted-value');
});
test('empty string is distinguished from unset', () => {
assert.equal(slotVerdict('SLACK_BOT_TOKEN', 'web-api', undefined)[0], 'unset');
assert.equal(slotVerdict('SLACK_BOT_TOKEN', 'web-api', '')[0], 'empty');
});
test('a value that is not a slack token at all', () => {
assert.equal(classify('ghp_abc')[0], 'not-a-slack-token');
assert.equal(slotVerdict('SLACK_BOT_TOKEN', 'web-api', 'ghp_abc')[0], 'not-a-slack-token');
});
FAQ
Why does an app-level token not work on the Web API?
Because it is not a workspace credential. An xapp- token is issued against the app itself and carries connections:write or authorizations:read, not channels:read or chat:write. It exists to open a Socket Mode connection and to read app event authorizations. There is no workspace identity behind it for auth.test to return, so the Web API rejects it rather than returning a diminished answer.
Is an xoxc- token from the browser ever acceptable?
No. It is the session credential of a signed-in human, it is bound to a cookie, it expires without warning, and using it means your automation acts as that person with all of their access. Slack does not support it and there is no version of the workaround that becomes supported later. Register an app and take an xoxb- token instead.
The prefix is right and I still get invalid_auth. What now?
Then the class is not the problem and the value is. In order of likelihood: the token was revoked by an uninstall, it belongs to a different workspace than the ids in your request, rotation replaced it and the stored copy is the old one, or the string was truncated or padded on its way into the environment. The script separates that case out as class-right-value-wrong for exactly this reason.
Can I just check the prefix at startup and skip the audit?
That is the recommended end state, and it is the last step in this note. The audit is what you run once to find out which of your deployed environments already has the swap, and to catch the values that pass a prefix check but carry a newline. Once the startup assertion is in place the swap cannot be deployed again.
Why not name the variable SLACK_TOKEN and work out the class at runtime?
Because the class determines what the credential can be used for, and deciding that at runtime means every call site has to handle both cases. Two variables named for their roles turn a runtime branch into a deployment fact, and make the startup assertion possible at all.
Related field notes
- the method refuses this class of token
- missing_scope names the scope you need
- every failure arrives as HTTP 200
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.
- Token types — Slack Docs
- auth.test method reference — Slack Docs
- Socket Mode — Slack Docs
- apps.connections.open method reference — Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.