Back to Blog
Guide

Slack Token Rotation: Refresh Tokens and xoxe Tokens, Tested Step by Step

With token rotation on, Slack access tokens start with xoxe. and expire after 12 hours. We turned it on for a test app on 2 October 2026, refreshed the token six times, and logged which old tokens kept working.

Slack Green Team
October 2, 2026
October 2, 2026
5 min read
Share:
slack api
developers
oauth

With token rotation turned on, Slack gives your app an access token that starts with xoxe.xoxb- (bot) or xoxe.xoxp- (user) and expires after 43,200 seconds, which is 12 hours. It also gives you a refresh token that starts with xoxe- and does not expire. Before the access token runs out, call oauth.v2.access with grant_type=refresh_token and the refresh token to get a new pair. You cannot turn rotation off again.

We tested this on 2 October 2026 with throwaway apps in a test workspace: one created with rotation on, and one with ordinary tokens that we opted in later. Every response below is what Slack returned. Token values are cut; we never show them.

Turn on token rotation

On a new app, set "token_rotation_enabled": true under settings in the app manifest. On an existing app, open OAuth & Permissions. The first box on the page is this one:

Slack app settings, OAuth and Permissions: the Advanced token security via token rotation box with its Opt In button

Clicking Opt In asks once more, and says plainly that this is permanent:

The confirmation dialog: Your app will permanently use token rotation. You will not be able to change your app tokens back to long-lived tokens after opting in.

What the install returns

We installed the app with rotation on through a normal OAuth flow (bot scopes chat:write,channels:read, user scope channels:read). The oauth.v2.access response now carries a refresh token and an expiry for both the bot and the user:

{
  "ok": true,
  "app_id": "A0C5Q3ZR4MV",
  "authed_user": {
    "id": "U0B7L4YK420",
    "scope": "channels:read",
    "access_token": "xoxe.xoxp-1-<cut>",
    "token_type": "user",
    "refresh_token": "xoxe-1-<cut>",
    "expires_in": 43200
  },
  "scope": "channels:read,chat:write",
  "token_type": "bot",
  "access_token": "xoxe.xoxb-1-<cut>",
  "bot_user_id": "U0C63GB2THU",
  "refresh_token": "xoxe-1-<cut>",
  "expires_in": 43200,
  "team": {"id": "T0B7JBCDKC1", "name": "Slack"},
  "is_enterprise_install": false
}

Both access tokens were 183 characters long and both refresh tokens 158. auth.test with a rotating token also tells you how long it has left, which is the easiest way to check a token you found in a config file:

{
  "ok": true,
  "user": "w2rotatebot",
  "team_id": "T0B7JBCDKC1",
  "user_id": "U0C63GB2THU",
  "bot_id": "B0C6063NQJZ",
  "expires_in": 43185,
  "is_enterprise_install": false
}

The app settings page shows the same thing in words. The access token says when it expires; the refresh token says it does not:

OAuth Tokens with rotation on: user and bot OAuth tokens with Token expires on Oct 2 at 10:19 PM, refresh tokens marked Does not expire. Token values blurred.

Refresh the token

curl -s https://slack.com/api/oauth.v2.access \
  -d client_id=$SLACK_CLIENT_ID \
  -d client_secret=$SLACK_CLIENT_SECRET \
  -d grant_type=refresh_token \
  -d refresh_token=$SLACK_REFRESH_TOKEN

Response:

{
  "ok": true,
  "access_token": "xoxe.xoxb-1-<cut>",
  "expires_in": 43200,
  "refresh_token": "xoxe-1-<cut>",
  "token_type": "bot",
  "app_id": "A0C5Q3ZR4MV",
  "scope": "channels:read,chat:write",
  "bot_user_id": "U0C63GB2THU",
  "team": {"id": "T0B7JBCDKC1", "name": "Slack"},
  "enterprise": null,
  "is_enterprise_install": false
}

Both values were new. The bot and the user each have their own refresh token, so refresh them separately. The user refresh returned "token_type": "user" and "scope": "identify,channels:read".

Which old tokens kept working

We refreshed six times, mixing current and old refresh tokens, and called auth.test with every access token after each step. A0 is the token from the install, R0 its refresh token.

StepRefresh token sentGot backOld access tokens after this step
1R0 (current)A1 and a new R1A0 still worked, with its own clock still running (43,180 s left)
2R0 (already used)A2 and R1 againnot checked
3R1 (current)A3 and a new R2A0 and A1: token_revoked. A2 still worked
4R0 (old)A4 and R2 againA2: token_revoked. A3 and A4 worked
5R1 (old)A5 and R2 againA3: token_revoked. A4 and A5 worked

Three things follow from this run:

  • • A refresh does not kill the access token you already have. In every check, the two newest access tokens worked and anything older returned {"ok": false, "error": "token_revoked"}. If several workers share one install, have one of them refresh and store the result. To kill a token on purpose instead of waiting for it to expire, use auth.revoke.
  • • An old refresh token did not fail. It returned a fresh access token plus the current refresh token. Only sending the current refresh token produced a new one. Still store the refresh token from every response, because you cannot tell from the response alone whether it changed.
  • • The new access token always started over at 43,200 seconds. Refreshing early does not lose time.

Never appear "away" on Slack again

Cloud-based. No downloads. Works 24/7 even when your laptop is off.

Errors from the refresh call

What we sentResponse
Refresh token with the last 3 characters changed{"ok": false, "error": "invalid_refresh_token"}
Wrong client_secret{"ok": false, "error": "bad_client_secret"}
No client_id or client_secret{"ok": false, "error": "invalid_client_id"}
Another app's client_id and secret{"ok": false, "error": "invalid_client_id"}

You cannot turn rotation off

After rotation is on, the Opt In box is gone from OAuth & Permissions and there is no Opt Out. Setting token_rotation_enabled back to false with apps.manifest.update was refused:

{
  "ok": false,
  "error": "invalid_manifest",
  "errors": [
    {
      "code": "cannot_disable_once_enabled",
      "message": "Token rotation cannot be disabled once enabled",
      "pointer": "/token_rotation_enabled"
    }
  ]
}

If you need long-lived tokens again, the way back is a new app. So test rotation on a throwaway app first, as we did.

Turning it on for an app that already has users

Existing installs keep their old xoxb- token after you opt in. Ours still passed auth.test after the opt-in. To move an install over without asking the user to reinstall, call oauth.v2.exchange with the old token:

curl -s https://slack.com/api/oauth.v2.exchange \
  -d client_id=$SLACK_CLIENT_ID \
  -d client_secret=$SLACK_CLIENT_SECRET \
  -d token=$OLD_XOXB_TOKEN
WhenResponse
Before opting in{"ok": false, "error": "token_rotation_not_enabled"}
After opting in"ok": true, an xoxe.xoxb-1- access token, an xoxe-1- refresh token, "expires_in": 43200
Same old token a second time{"ok": false, "error": "token_already_exchanged"}

The old xoxb- token still passed auth.test right after the exchange. Plan to stop using it, but do not count on it dying the moment you exchange it.

Refresh before it expires, in Python

This stores the pair in a file and refreshes when less than 5 minutes are left:

import os, json, time
from slack_sdk import WebClient

STORE = "slack_token.json"  # {"access_token", "refresh_token", "expires_at"}

def get_bot_token():
    tok = json.load(open(STORE))
    if time.time() < tok["expires_at"] - 300:  # still valid for 5+ minutes
        return tok["access_token"]
    r = WebClient().oauth_v2_access(
        client_id=os.environ["SLACK_CLIENT_ID"],
        client_secret=os.environ["SLACK_CLIENT_SECRET"],
        grant_type="refresh_token",
        refresh_token=tok["refresh_token"],
    )
    tok = {"access_token": r["access_token"],
           "refresh_token": r["refresh_token"],  # save it: it may be new
           "expires_at": int(time.time()) + r["expires_in"]}
    json.dump(tok, open(STORE, "w"))
    return tok["access_token"]

token = get_bot_token()
print("auth.test:", WebClient(token=token).auth_test()["ok"],
      "| prefix:", token[:10], "| expires in", json.load(open(STORE))["expires_at"] - int(time.time()), "s")

We set the stored token to expire in 60 seconds and ran it twice:

run 1 (stored token set to expire in 60 s):
auth.test: True | prefix: xoxe.xoxb- | expires in 43200 s
run 2 (a few seconds later):
auth.test: True | prefix: xoxe.xoxb- | expires in 43196 s

The first run refreshed; the second reused the stored token. For the full list of token prefixes see Slack bot tokens.

FAQ

Do app-level tokens (xapp-) rotate too? They have their own setting. Basic Information has a separate Token rotation switch under App-Level Tokens, with the note that it cannot be undone. The OAuth token rotation on this page covers bot and user tokens.

Always Active

Stop Jiggling Your Mouse.

Join hundreds of remote workers who never worry about their Slack status. Set it up once, stay green forever.

Related Articles

Guide

assistant.threads.setStatus vs agents.sessions.setStatus: Both Tested in Slack

We called assistant.threads.setStatus and agents.sessions.setStatus on a test app before and after turning on the agent feature, clicked the stop button, and timed how long the status line stays. Every response, the stop event, and what Slack showed.

Slack Green Team
Guide

Slack Slash Command Payload: Every Field, Responses, and response_url Limits

We caught the payload a slash command sends, answered it as ephemeral, in_channel, plain text and empty, posted to its response_url until it failed, and typed the command inside a thread. Every result is from a test app on 2 October 2026.

Slack Green Team
Guide

Slackbot MCP Client: We Connected a 17-Line MCP Server and Slackbot Called It

We wrote a one-tool MCP server, added it to a Slack app with the mcp_servers manifest field, switched it on in Slackbot and asked for the time. Every request Slackbot sent to the server, the permission prompt, and what Slackbot said when the server was down.

Slack Green Team