Implementing Slack Token Rotation: Refresh Grants Without Locking Out Your Bot
Slack's rotating tokens expire every 12 hours. Learn to handle the refresh-token grant, persist new tokens atomically, and recover from token_expired so your ops bot never locks out.
- #slack
- #ai
- #oauth
- #tokens
- #security
We enabled token rotation on a Slack app because security asked us to, shipped it, and twelve hours and one minute later the bot went dark across every workspace it was installed in. The refresh logic worked in testing because testing never ran long enough to hit the twelve-hour expiry. In production, the access token expired, our refresh call fired, it succeeded — and then a race between two processes both writing the token store left one of them holding a stale refresh token, which Slack had already invalidated. Rotation is a genuine security improvement, but it turns your token store into a live, expiring thing that demands careful handling.
This is a guide to implementing Slack’s rotating tokens correctly: handling the refresh-token grant, persisting new tokens atomically, and recovering when a call returns token_expired. Rotation trades a long-lived secret for a short-lived one plus a refresh token, which shrinks the blast radius of a leaked token from “forever” to “twelve hours.” That’s worth having — but only if the refresh machinery doesn’t lock you out.
How rotation changes the token model
Without rotation, OAuth gives you a bot token (xoxb-...) that lives until revoked. With rotation enabled (via token_rotation_enabled in your manifest, or by opting in during OAuth with &grant_type-style flows), the install returns three things instead of one:
- an access token that expires in about 12 hours
- a refresh token used to obtain the next access token
- an expiry timestamp (
expires_in/exp) telling you when to refresh
The access token is what you call the API with. The refresh token is what you exchange, via oauth.v2.access with grant_type=refresh_token, for a fresh access token and a new refresh token. That last part is the trap: each refresh rotates the refresh token too, and the old one becomes invalid the moment the new one is issued. If you ever reuse an old refresh token — because two processes refreshed concurrently, or because a write didn’t persist — Slack rejects it and that installation is locked out until someone re-installs. Securing tokens is foundational to running ops apps on Slack, and rotation raises the stakes on getting the storage right.
The refresh call
Refresh proactively, before expiry, not reactively after a failure. Check the stored expiry on each use and refresh when you’re inside a safety margin:
async function getValidToken(teamId) {
const inst = await store.get(teamId);
const now = Math.floor(Date.now() / 1000);
if (inst.exp - now > 300) { // more than 5 min left — use as-is
return inst.access_token;
}
return await refreshToken(teamId); // refresh with margin to spare
}
async function refreshToken(teamId) {
const inst = await store.get(teamId);
const res = await client.oauth.v2.access({
client_id: process.env.SLACK_CLIENT_ID,
client_secret: process.env.SLACK_CLIENT_SECRET,
grant_type: 'refresh_token',
refresh_token: inst.refresh_token,
});
// CRITICAL: persist BOTH the new access token AND the new refresh token
await store.update(teamId, {
access_token: res.access_token,
refresh_token: res.refresh_token, // the old one is now dead
exp: Math.floor(Date.now() / 1000) + res.expires_in,
});
return res.access_token;
}
The comment on refresh_token is the whole ballgame. Persist the new refresh token, because the old one is already invalid. Forget that line and the next refresh fails with an invalid refresh token, and the installation locks out.
The concurrency problem that locked us out
The race that took us down: two requests for the same workspace both noticed the token was near expiry, both called refresh, and both got valid responses — but Slack had rotated the refresh token on the first call, so the second call’s response was based on a now-superseded chain, and whichever write landed last clobbered the valid token with a stale one. The fix is to serialize refreshes per workspace so only one runs at a time:
const refreshLocks = new Map(); // teamId -> in-flight promise
async function refreshTokenSafe(teamId) {
if (refreshLocks.has(teamId)) return refreshLocks.get(teamId); // coalesce
const p = refreshToken(teamId).finally(() => refreshLocks.delete(teamId));
refreshLocks.set(teamId, p);
return p;
}
In a single process this in-memory lock suffices; across multiple processes you need a distributed lock (Redis SET NX, a row lock) so the whole fleet agrees on one refresh per workspace. Concurrent refreshes are the single most common way rotation goes wrong, and serialization is the cure.
Recovering from token_expired
Even with proactive refresh, you’ll occasionally make a call with a token that just expired — clock skew, a long-running job that started with a fresh token and outran it. Treat token_expired as a refresh-and-retry signal, exactly once, to avoid loops:
async function callWithRefresh(teamId, apiCall) {
try {
return await apiCall(await getValidToken(teamId));
} catch (err) {
if (err.data?.error === 'token_expired') {
const fresh = await refreshTokenSafe(teamId);
return await apiCall(fresh); // retry once with a fresh token
}
throw err;
}
}
One retry, then give up — a refresh that produces a token Slack still considers expired means something deeper is wrong (likely a clobbered refresh token), and retrying forever just hides it.
Where AI helps, and the one line it forgets
An LLM scaffolds the refresh flow competently, because OAuth refresh is a well-trodden pattern. A prompt gets you the structure:
Write a Slack token-rotation refresh handler that exchanges a refresh token via oauth.v2.access grant_type=refresh_token, persists the new access and refresh tokens with the new expiry, serializes refreshes per workspace, and retries token_expired once.
The draft will be close. The thing the AI reliably underweights — and the thing a human must verify — is persisting the new refresh token, and serializing concurrent refreshes. Generic OAuth examples often store only the access token, because many providers don’t rotate the refresh token. Slack does. The AI drafts the flow; the human confirms both halves of the response are persisted and that two processes can’t refresh at once. Keep the corrected pattern in a prompt library alongside the related token storage and installation store prompt so rotation is handled the same way everywhere.
Wrapping Up
Token rotation is a real security win — it caps a leaked Slack token’s usefulness at roughly twelve hours instead of forever — but it converts your token store into a living thing that locks you out if mishandled. The non-negotiables are persisting the new refresh token on every exchange (the old one dies instantly), serializing refreshes per workspace so concurrent calls can’t clobber each other, and treating token_expired as a refresh-once-and-retry signal. Let AI scaffold the flow, then verify those two details yourself, because the gap between “the refresh call succeeded” and “the installation stayed alive” is exactly where rotation goes dark twelve hours after you ship it.
Download the Free 500-Prompt DevOps AI Toolkit
500 battle-tested, copy-paste AI prompts engineered by a senior systems engineer — every one with fill-in placeholders and safety/back-out notes. Drop your email and it's yours.
- 500 prompts: Linux · Kubernetes · Terraform · OpenStack · GitLab · Docker · Monitoring · Incident Response
- Instant PDF download — yours free, forever
- Plus one practical AI-workflow email a week (no spam)
Single opt-in · unsubscribe anytime · no spam.