Diagnostic Slack
token_expired every 12 hours because rotation is on
The app is perfect for half a day after every deploy and then every call returns {"ok": false, "error": "token_expired"}. A nightly redeploy hid it for four months, because each deploy ran the install flow again and each install handed back a fresh token. Then the redeploy was removed as an optimisation, and the app started dying every lunchtime.
Look at the token string before you call anything. A leading xoxe. means rotation is enabled: that access token expires 43200 seconds — twelve hours — after it was issued, and the install response also returned an xoxe-1- refresh token you were expected to keep. If you did not keep it, this install is permanently broken on a twelve-hour cycle, because rotation cannot be switched off once it is on.
The finding is arithmetic, not an error: obtained_at + expires_in against the clock tells you which installs expire tonight, hours before any of them fail. auth.test then confirms, and the interesting result is when the two disagree.
The problem in plain words
Token rotation is a good idea that is easy to turn on by accident. It is a switch in the app configuration, and it is also a line in an app manifest — token_rotation_enabled: true — so adopting a manifest that somebody else wrote, or copying one from a template, enables it for an app whose code has never heard of refresh tokens. The install flow still succeeds. Every call still works. For twelve hours.
What makes it hard to see is the shape of the symptom. It is not a failure that happens under load, or on one endpoint, or for one workspace: it is a failure that happens at a fixed offset from the last deploy, which means anything that redeploys regularly looks completely healthy. Nightly CI, a platform that recycles containers, an autoscaler that replaces instances — each of these silently re-runs whatever obtains the token and resets the clock. The bug surfaces the day the deploy cadence slows down.
And rotation is one-way. Slack does not offer a switch to turn it back off, so "just disable it" is not among the options no matter how attractive it sounds at 3am. The only way out is forward: store both halves of the pair, refresh before expiry, and persist the new pair atomically.
Why it happens
The prefix is the tell, and it is on the token itself. Rotated access tokens arrive as xoxe.xoxb- or xoxe.xoxp-; the companion refresh token starts xoxe-1-. A classic xoxb- token does not expire. One string comparison, no network call, tells you which regime an install is in.
expires_in is 43200 and it is a lifetime, not a deadline. It counts from issue, so a stored token is only meaningful next to the timestamp at which you received it. A store that persisted the token and dropped obtained_at cannot compute an expiry at all, which is a finding in its own right: you cannot schedule a refresh you cannot date.
Refresh at half life, not at expiry. A job scheduled for the moment of expiry has no room for a failed request, a slow deploy, or a clock skew. expires_in / 2 gives six hours of slack and costs one extra call a day.
Both halves change on every refresh. oauth.v2.access with grant_type=refresh_token returns a new access token and a new refresh token, and the old refresh token is single use. Persist the pair in one transaction; writing the access token and losing the refresh token converts a twelve-hour problem into a reinstall.
Rotation cannot be disabled. Once enabled on an app it stays enabled, for every installation, forever. That is why an app that opted in without building the refresh loop is not intermittently broken but permanently broken on a schedule.
The fix, as a flow
This is the one finding in the section that exists before any request fails. The prefix says which regime the install is in and the stored timestamp says how much of the twelve hours is left, so the report is written in the afternoon about an outage due at midnight.
How to fix it
Export what you persisted from the OAuth response
Per row: the token, the refresh token if you kept one, expires_in, and the timestamp at which the pair was obtained or last refreshed. This audit is arithmetic over those four fields, so an export that only has the token can tell you the regime and nothing about the schedule.
Read the prefix of every token
xoxe. means a rotated access token, xoxe-1- a refresh token, xoxb- or xoxp- a classic one that does not expire. A refresh token sitting in the variable your Web API client reads is its own bug, and this check catches it without a network call.
Compute the expiry from what you stored
obtained_at + expires_in against now, sorted into fresh, past the halfway mark, and already expired. This is the part that runs before anything breaks: an install past its half life with no scheduled refresh will fail tonight, and it says so this afternoon.
Report the install with no refresh token loudly
Rotation on and no xoxe-1- stored is the terminal case. There is nothing to refresh with, rotation cannot be turned off, and the only repair is a fresh OAuth install that keeps both halves. Everything else in this audit is a schedule; this one is an outage on a timer.
Confirm with auth.test, and read the disagreements
A live rotated token answers ok: true; an expired one answers token_expired. The valuable result is when the clock and the API disagree: a token your store thinks is fresh but Slack has expired means the stored timestamp is wrong, and one your store thinks is dead but Slack accepts means another replica refreshed and you are holding a stale row.
Build the refresh loop, or adopt the SDK that has one
The printed repair is a form-encoded call to oauth.v2.access with client_id, client_secret, grant_type=refresh_token and the stored refresh token, persisting both returned values atomically and scheduling the next refresh at half life. Bolt's installation store does this for you when rotation is enabled, which is the shorter route.
How to check it worked
Re-run a few hours after the refresh job has run once. Every rotated install should be fresh, and the clock and the API should agree on every row.
python3 slack_rotation_clock.py --store installs.json
# fresh acme 5.2 hour(s) of life left of 43200s; refresh due at 6.0h
# 4 row(s) checked, 0 expiring unrefreshed, 0 disagreeing with the API
The full code
The detection happens before the network call: token_shape reads the prefix and clock does arithmetic over the record you persisted, so the finding exists hours before any request fails. One auth.test per row then confirms it, confirm reads that answer, and agreement compares the two — because a store that disagrees with Slack about when a token dies is its own bug.
"""Find Slack installs where token rotation is on and nothing refreshes them.
Read only. The detection is arithmetic over what you persisted; one GET per row
confirms it. Nothing is refreshed here: minting a token is a write, and the
refresh call is printed for a human to run.
"""
import argparse
import json
import logging
import os
import sys
from datetime import datetime
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_rotation_clock")
API = "https://slack.com/api/"
# Every rotated access token Slack issues carries this lifetime. It is a
# constant, not a per-app setting, which is why a stored expires_in that is not
# 43200 usually means the field was copied from somewhere else.
ROTATED_LIFETIME = 43200
def token_shape(token):
"""Which regime a token string belongs to. Pure, and needs no network call.
The xoxe. check comes first: a rotated access token is xoxe.xoxb-, and a
refresh token is xoxe-1-, so a prefix test that starts with "xoxe-" would
swallow both and report the wrong one.
"""
if not token:
return "absent"
if token.startswith("xoxe.xoxb-") or token.startswith("xoxe.xoxp-"):
return "rotating"
if token.startswith("xoxe-"):
return "refresh"
if token.startswith("xoxb-") or token.startswith("xoxp-"):
return "classic"
if token.startswith("xapp-"):
return "app-level"
return "unrecognised"
def parse_ts(text):
"""The timestamp shapes an installation store actually holds."""
if not text:
return None
try:
return datetime.strptime(str(text)[:19], "%Y-%m-%dT%H:%M:%S")
except ValueError:
pass
try:
return datetime.strptime(str(text)[:10], "%Y-%m-%d")
except ValueError:
return None
def clock(row, now):
"""Where this install is in its twelve hour cycle. Pure.
`row` carries expires_in, has_refresh_token, and the moment the pair was
obtained or last refreshed. This runs before any request fails, which is the
only reason the audit is worth running at all.
"""
expires_in = row.get("expires_in")
if not expires_in:
return ("not-rotating",
"no expires_in persisted. A classic xoxb- token does not expire; "
"if the token string starts xoxe. then the expiry is real and you "
"did not record it.")
if not row.get("has_refresh_token"):
return ("no-refresh-token",
"rotation is on and no refresh token is stored. Rotation cannot "
"be switched off, so this install breaks every %d hour(s) until a "
"fresh OAuth install keeps both halves." % (expires_in // 3600))
started = parse_ts(row.get("refreshed_at") or row.get("obtained_at"))
if started is None:
return ("clock-unknown",
"expires_in is %ds and nothing records when the pair was issued. "
"You cannot schedule a refresh you cannot date." % expires_in)
age = (now - started).total_seconds()
half = expires_in / 2.0
if age >= expires_in:
return ("expired",
"issued %.1f hour(s) ago against a %ds life. Every call is "
"answering token_expired." % (age / 3600.0, expires_in))
if age >= half:
return ("overdue",
"%.1f hour(s) old, past the %.1f hour halfway mark. Refresh at "
"expires_in/2, not at expiry: a job scheduled for the deadline "
"has no room for a failed request."
% (age / 3600.0, half / 3600.0))
return ("fresh",
"%.1f hour(s) of life left of %ds; refresh due at %.1fh"
% ((expires_in - age) / 3600.0, expires_in, half / 3600.0))
def confirm(body):
"""What Slack says about the same token, right now. Pure."""
if body.get("ok") is True:
return ("live", "auth.test succeeded for team %s" % (body.get("team_id") or "?"))
error = body.get("error")
if error == "token_expired":
return ("expired", "auth.test answered token_expired")
if error == "token_revoked":
return ("revoked", "token_revoked, which is an uninstall rather than an "
"expiry and wants a different repair")
if error == "invalid_auth":
return ("invalid", "invalid_auth: the string does not authenticate at all")
return ("unusable", "error=%s" % (error or "<no error field>"))
def agreement(clock_state, live_state):
"""Compare the store's arithmetic against the API's answer. Pure.
A store that disagrees with Slack about when a token dies is its own bug,
and it is the finding that a purely live check can never produce.
"""
if live_state in ("revoked", "invalid", "unusable"):
return ("unrelated",
"the token failed for a reason that has nothing to do with "
"rotation, so the clock says nothing useful here")
if clock_state in ("expired", "overdue") and live_state == "live":
return ("store-behind",
"your record says this token is spent and Slack still accepts it. "
"Something else refreshed it and did not write back, so the row "
"you are holding is stale")
if clock_state == "fresh" and live_state == "expired":
return ("store-ahead",
"your record says hours of life remain and Slack has already "
"expired it. The stored timestamp or expires_in is wrong, or "
"another refresh replaced this token")
if clock_state in ("no-refresh-token", "clock-unknown"):
return ("unknowable",
"the record cannot be reconciled with anything: fix what is "
"persisted before trusting either side")
return ("agree", "the clock and the API agree")
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 load_rows(path):
if path:
return json.loads(open(path, encoding="utf-8").read())
return [{"key": "<the only row>", "token_env": "SLACK_BOT_TOKEN",
"refresh_token_env": "SLACK_REFRESH_TOKEN",
"expires_in": ROTATED_LIFETIME,
"obtained_at": os.environ.get("SLACK_TOKEN_OBTAINED_AT")}]
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--store", help="JSON array of installation rows: token_env, "
"refresh_token_env, expires_in, obtained_at")
args = ap.parse_args()
rows = load_rows(args.store)
s = requests.Session()
now = datetime.utcnow()
expiring = 0
disagreeing = 0
for row in rows:
token = os.environ.get(row.get("token_env") or "SLACK_BOT_TOKEN")
if not token:
log.warning("%-17s %-10s row names %s and it is unset", "no-token",
row.get("key"), row.get("token_env"))
continue
shape = token_shape(token)
if shape == "refresh":
log.warning("%-17s %-10s the Web API variable holds an xoxe-1- refresh "
"token. That is the other half of the pair.",
"wrong-half", row.get("key"))
expiring += 1
continue
if shape in ("classic", "app-level", "unrecognised"):
log.info("%-17s %-10s %s token: rotation is not enabled for this "
"install", shape, row.get("key"), shape)
continue
has_refresh = bool(os.environ.get(row.get("refresh_token_env") or ""))
state, detail = clock(dict(row, has_refresh_token=has_refresh), now)
body = auth_test(s, token)
live_state, live_detail = confirm(body)
verdict, why = agreement(state, live_state)
line = "%-17s %-10s %s" % (state, row.get("key"), detail)
if state == "fresh":
log.info(line)
else:
expiring += 1
log.warning(line)
log.info("%-17s %-10s %s", "api-says", row.get("key"), live_detail)
if verdict != "agree":
disagreeing += 1
log.warning("%-17s %-10s %s", verdict, row.get("key"), why)
if state in ("no-refresh-token", "expired", "overdue", "clock-unknown"):
log.warning(" repair: form-encoded call to %soauth.v2.access with "
"client_id, client_secret, grant_type=refresh_token and the "
"stored xoxe-1- token; persist both returned values in one "
"transaction and schedule the next run at expires_in/2", API)
log.info("%d row(s) checked, %d expiring unrefreshed, %d disagreeing with the API",
len(rows), expiring, disagreeing)
return 1 if (expiring or disagreeing) else 0
if __name__ == "__main__":
sys.exit(main())
/**
* Find Slack installs where token rotation is on and nothing refreshes them.
*
* Read only. The detection is arithmetic over what you persisted; one GET per
* row confirms it. Nothing is refreshed here: minting a token is a write, and
* the refresh call is printed for a human to run.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
// Every rotated access token Slack issues carries this lifetime. It is a
// constant, not a per-app setting, which is why a stored expires_in that is not
// 43200 usually means the field was copied from somewhere else.
export const ROTATED_LIFETIME = 43200;
/**
* Which regime a token string belongs to. Pure, and needs no network call.
* The xoxe. check comes first: a rotated access token is xoxe.xoxb- and a
* refresh token is xoxe-1-, so a test on "xoxe-" alone reports the wrong one.
*/
export function tokenShape(token) {
if (!token) return 'absent';
if (token.startsWith('xoxe.xoxb-') || token.startsWith('xoxe.xoxp-')) return 'rotating';
if (token.startsWith('xoxe-')) return 'refresh';
if (token.startsWith('xoxb-') || token.startsWith('xoxp-')) return 'classic';
if (token.startsWith('xapp-')) return 'app-level';
return 'unrecognised';
}
/** The timestamp shapes an installation store actually holds. */
export function parseTs(text) {
if (!text) return null;
const d = new Date(text);
return Number.isNaN(d.getTime()) ? null : d;
}
/**
* Where this install is in its twelve hour cycle. Pure. This runs before any
* request fails, which is the only reason the audit is worth running at all.
*/
export function clock(row, now) {
const expiresIn = row.expires_in;
if (!expiresIn) {
return ['not-rotating',
'no expires_in persisted. A classic xoxb- token does not expire; if the ' +
'token string starts xoxe. then the expiry is real and you did not record it.'];
}
if (!row.has_refresh_token) {
return ['no-refresh-token',
'rotation is on and no refresh token is stored. Rotation cannot be switched ' +
`off, so this install breaks every ${Math.floor(expiresIn / 3600)} hour(s) ` +
'until a fresh OAuth install keeps both halves.'];
}
const started = parseTs(row.refreshed_at || row.obtained_at);
if (started === null) {
return ['clock-unknown',
`expires_in is ${expiresIn}s and nothing records when the pair was issued. ` +
'You cannot schedule a refresh you cannot date.'];
}
const age = (now.getTime() - started.getTime()) / 1000;
const half = expiresIn / 2;
if (age >= expiresIn) {
return ['expired',
`issued ${(age / 3600).toFixed(1)} hour(s) ago against a ${expiresIn}s life. ` +
'Every call is answering token_expired.'];
}
if (age >= half) {
return ['overdue',
`${(age / 3600).toFixed(1)} hour(s) old, past the ${(half / 3600).toFixed(1)} ` +
'hour halfway mark. Refresh at expires_in/2, not at expiry: a job scheduled ' +
'for the deadline has no room for a failed request.'];
}
return ['fresh',
`${((expiresIn - age) / 3600).toFixed(1)} hour(s) of life left of ${expiresIn}s; ` +
`refresh due at ${(half / 3600).toFixed(1)}h`];
}
/** What Slack says about the same token, right now. Pure. */
export function confirm(body) {
if (body?.ok === true) {
return ['live', `auth.test succeeded for team ${body.team_id ?? '?'}`];
}
const error = body?.error;
if (error === 'token_expired') return ['expired', 'auth.test answered token_expired'];
if (error === 'token_revoked') {
return ['revoked',
'token_revoked, which is an uninstall rather than an expiry and wants a ' +
'different repair'];
}
if (error === 'invalid_auth') {
return ['invalid', 'invalid_auth: the string does not authenticate at all'];
}
return ['unusable', `error=${error ?? '<no error field>'}`];
}
/**
* Compare the store's arithmetic against the API's answer. Pure. A store that
* disagrees with Slack about when a token dies is its own bug, and it is the
* finding a purely live check can never produce.
*/
export function agreement(clockState, liveState) {
if (['revoked', 'invalid', 'unusable'].includes(liveState)) {
return ['unrelated',
'the token failed for a reason that has nothing to do with rotation, so the ' +
'clock says nothing useful here'];
}
if (['expired', 'overdue'].includes(clockState) && liveState === 'live') {
return ['store-behind',
'your record says this token is spent and Slack still accepts it. Something ' +
'else refreshed it and did not write back, so the row you are holding is stale'];
}
if (clockState === 'fresh' && liveState === 'expired') {
return ['store-ahead',
'your record says hours of life remain and Slack has already expired it. The ' +
'stored timestamp or expires_in is wrong, or another refresh replaced this token'];
}
if (['no-refresh-token', 'clock-unknown'].includes(clockState)) {
return ['unknowable',
'the record cannot be reconciled with anything: fix what is persisted before ' +
'trusting either side'];
}
return ['agree', 'the clock and the API agree'];
}
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 loadRows(path) {
if (path) return JSON.parse(await readFile(path, 'utf8'));
return [{
key: '<the only row>',
token_env: 'SLACK_BOT_TOKEN',
refresh_token_env: 'SLACK_REFRESH_TOKEN',
expires_in: ROTATED_LIFETIME,
obtained_at: process.env.SLACK_TOKEN_OBTAINED_AT,
}];
}
async function main() {
const args = process.argv.slice(2);
const i = args.indexOf('--store');
const store = i === -1 ? null : args[i + 1];
const rows = await loadRows(store);
const now = new Date();
let expiring = 0;
let disagreeing = 0;
for (const row of rows) {
const token = process.env[row.token_env ?? 'SLACK_BOT_TOKEN'];
if (!token) {
console.warn(`${'no-token'.padEnd(17)} ${String(row.key).padEnd(10)} row names ` +
`${row.token_env} and it is unset`);
continue;
}
const shape = tokenShape(token);
if (shape === 'refresh') {
console.warn(`${'wrong-half'.padEnd(17)} ${String(row.key).padEnd(10)} the Web ` +
'API variable holds an xoxe-1- refresh token. That is the other half of the pair.');
expiring += 1;
continue;
}
if (['classic', 'app-level', 'unrecognised'].includes(shape)) {
console.log(`${shape.padEnd(17)} ${String(row.key).padEnd(10)} ${shape} token: ` +
'rotation is not enabled for this install');
continue;
}
const hasRefresh = Boolean(process.env[row.refresh_token_env ?? '']);
const [state, detail] = clock({ ...row, has_refresh_token: hasRefresh }, now);
const body = await authTest(token);
const [liveState, liveDetail] = confirm(body);
const [verdict, why] = agreement(state, liveState);
const line = `${state.padEnd(17)} ${String(row.key).padEnd(10)} ${detail}`;
if (state === 'fresh') {
console.log(line);
} else {
expiring += 1;
console.warn(line);
}
console.log(`${'api-says'.padEnd(17)} ${String(row.key).padEnd(10)} ${liveDetail}`);
if (verdict !== 'agree') {
disagreeing += 1;
console.warn(`${verdict.padEnd(17)} ${String(row.key).padEnd(10)} ${why}`);
}
if (['no-refresh-token', 'expired', 'overdue', 'clock-unknown'].includes(state)) {
console.warn(` repair: form-encoded call to ${API}oauth.v2.access with ` +
'client_id, client_secret, grant_type=refresh_token and the stored xoxe-1- ' +
'token; persist both returned values in one transaction and schedule the ' +
'next run at expires_in/2');
}
}
console.log(`${rows.length} row(s) checked, ${expiring} expiring unrefreshed, ` +
`${disagreeing} disagreeing with the API`);
process.exitCode = expiring || disagreeing ? 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
Two things have to be pinned. The prefix test must return rotating for xoxe.xoxb- and refresh for xoxe-1-, because a check written as "starts with xoxe" swallows both and cheerfully reports a refresh token as a healthy access token. And the halfway mark must be a finding while the token still works — a script that only reports expired tokens has waited until the outage to speak.
from datetime import datetime
from slack_rotation_clock import agreement, clock, confirm, token_shape
NOW = datetime(2026, 8, 30, 12, 0, 0)
ROTATED = {"expires_in": 43200, "has_refresh_token": True}
def test_the_two_xoxe_prefixes_are_not_the_same_thing():
assert token_shape("xoxe.xoxb-1-abc") == "rotating"
assert token_shape("xoxe-1-abc") == "refresh"
assert token_shape("xoxb-1-abc") == "classic"
assert token_shape("xapp-1-abc") == "app-level"
assert token_shape(None) == "absent"
def test_rotation_on_with_nothing_to_refresh_with_is_terminal():
state, detail = clock({"expires_in": 43200, "has_refresh_token": False}, NOW)
assert state == "no-refresh-token"
assert "cannot be switched off" in detail
def test_a_classic_token_is_not_rotating():
assert clock({"has_refresh_token": False}, NOW)[0] == "not-rotating"
def test_past_the_halfway_mark_is_a_finding_while_it_still_works():
row = dict(ROTATED, obtained_at="2026-08-30T04:00:00Z")
state, detail = clock(row, NOW)
assert state == "overdue"
assert "expires_in/2" in detail
def test_still_inside_the_first_half_is_quiet():
assert clock(dict(ROTATED, obtained_at="2026-08-30T11:00:00Z"), NOW)[0] == "fresh"
def test_past_the_lifetime_is_expired():
assert clock(dict(ROTATED, obtained_at="2026-08-29T20:00:00Z"), NOW)[0] == "expired"
def test_a_pair_with_no_timestamp_cannot_be_scheduled():
state, detail = clock(ROTATED, NOW)
assert state == "clock-unknown"
assert "cannot date" in detail
def test_the_last_refresh_wins_over_the_original_issue():
row = dict(ROTATED, obtained_at="2026-08-01T00:00:00Z",
refreshed_at="2026-08-30T11:00:00Z")
assert clock(row, NOW)[0] == "fresh"
def test_confirm_separates_expiry_from_uninstall():
assert confirm({"ok": True, "team_id": "T1"})[0] == "live"
assert confirm({"ok": False, "error": "token_expired"})[0] == "expired"
assert confirm({"ok": False, "error": "token_revoked"})[0] == "revoked"
def test_store_and_api_disagreeing_is_its_own_finding():
assert agreement("expired", "live")[0] == "store-behind"
assert agreement("fresh", "expired")[0] == "store-ahead"
assert agreement("fresh", "live")[0] == "agree"
assert agreement("expired", "revoked")[0] == "unrelated"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { agreement, clock, confirm, tokenShape } from './slack-rotation-clock.mjs';
const NOW = new Date('2026-08-30T12:00:00Z');
const ROTATED = { expires_in: 43200, has_refresh_token: true };
test('the two xoxe prefixes are not the same thing', () => {
assert.equal(tokenShape('xoxe.xoxb-1-abc'), 'rotating');
assert.equal(tokenShape('xoxe-1-abc'), 'refresh');
assert.equal(tokenShape('xoxb-1-abc'), 'classic');
assert.equal(tokenShape('xapp-1-abc'), 'app-level');
assert.equal(tokenShape(null), 'absent');
});
test('rotation on with nothing to refresh with is terminal', () => {
const [state, detail] = clock({ expires_in: 43200, has_refresh_token: false }, NOW);
assert.equal(state, 'no-refresh-token');
assert.match(detail, /cannot be switched off/);
});
test('a classic token is not rotating', () => {
assert.equal(clock({ has_refresh_token: false }, NOW)[0], 'not-rotating');
});
test('past the halfway mark is a finding while it still works', () => {
const [state, detail] = clock({ ...ROTATED, obtained_at: '2026-08-30T04:00:00Z' }, NOW);
assert.equal(state, 'overdue');
assert.match(detail, /expires_in\/2/);
});
test('still inside the first half is quiet', () => {
assert.equal(clock({ ...ROTATED, obtained_at: '2026-08-30T11:00:00Z' }, NOW)[0], 'fresh');
});
test('past the lifetime is expired', () => {
assert.equal(clock({ ...ROTATED, obtained_at: '2026-08-29T20:00:00Z' }, NOW)[0], 'expired');
});
test('a pair with no timestamp cannot be scheduled', () => {
const [state, detail] = clock(ROTATED, NOW);
assert.equal(state, 'clock-unknown');
assert.match(detail, /cannot date/);
});
test('the last refresh wins over the original issue', () => {
const row = { ...ROTATED, obtained_at: '2026-08-01T00:00:00Z',
refreshed_at: '2026-08-30T11:00:00Z' };
assert.equal(clock(row, NOW)[0], 'fresh');
});
test('confirm separates expiry from uninstall', () => {
assert.equal(confirm({ ok: true, team_id: 'T1' })[0], 'live');
assert.equal(confirm({ ok: false, error: 'token_expired' })[0], 'expired');
assert.equal(confirm({ ok: false, error: 'token_revoked' })[0], 'revoked');
});
test('store and api disagreeing is its own finding', () => {
assert.equal(agreement('expired', 'live')[0], 'store-behind');
assert.equal(agreement('fresh', 'expired')[0], 'store-ahead');
assert.equal(agreement('fresh', 'live')[0], 'agree');
assert.equal(agreement('expired', 'revoked')[0], 'unrelated');
});
FAQ
How do I know rotation is enabled without asking anyone?
Look at the token. A rotated access token starts xoxe.xoxb- or xoxe.xoxp- and a classic one starts xoxb- or xoxp-. If you kept the OAuth response, a non-null expires_in and a refresh_token field say the same thing. With an app configuration token, apps.manifest.export reports settings.token_rotation_enabled directly, but that is a different credential class from the bot token your app runs on.
Can I turn rotation off again?
No. Slack does not offer a way back once rotation is enabled on an app, which is why enabling it by adopting somebody else's manifest is such an expensive accident. The only route is to build the refresh loop, or to use an SDK installation store that already has one.
Why refresh at half life rather than just before expiry?
Because a refresh scheduled at the deadline has no margin. Six hours of slack absorbs a failed request, a deploy that overruns, a paused worker and a clock that drifts, and it costs one extra call per day. Refreshing on every request is the other extreme and burns the two-active-token limit.
My token expired and the refresh token no longer works either. Now what?
A fresh OAuth install, and then a look at how many workers refresh concurrently. Slack refresh tokens are single use: two replicas refreshing at once, or a retry after a response that was lost in transit, both burn the token twice and revoke the pair. Serialise refreshes behind a per-installation lock.
Does the nightly redeploy that hid this count as a fix?
No, it is a coincidence with a deployment schedule attached. Anything that re-runs the install flow resets the twelve-hour clock, which is why the bug appears the week somebody removes a redundant deploy or lengthens the release cadence. The refresh loop is the fix; the redeploy just moved the failure into the future.
Related field notes
- token_revoked means the app is gone
- 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.
- Using token rotation — Slack Docs
- oauth.v2.access method reference — Slack Docs
- auth.test method reference — Slack Docs
- Token types — Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.