Vintage illustration of sailors navigating rough waters
← Back to blog

INTEGRATION GUIDE

Slack OAuth in Python: Bot vs User Tokens

The full v2 flow in runnable slack_sdk code, bot versus user tokens decided with a table, token rotation, the scope mistakes that cost a day, and the real rate-limit numbers.

•Nov 22, 2025•Updated Sep 29, 2026•15 min
OAuthSlackPython

TL;DR

The decision that shapes a Slack integration is not the OAuth flow. It is bot token or user token, and you make it by writing the scope list, before any code runs. Get it wrong and the fix is a reinstall across every workspace you have.

The rule underneath it is simple. A bot token is your app. A user token "represent[s] the same access a user has to a workspace", so it sees private channels and DMs that person can see, and it dies with their account. Slack's own guidance is that "using bot tokens is usually for the best."

The flow itself is four steps and slack_sdk ships helpers for three of them. What breaks is procedural: a state value never checked, a code exchanged after its ten-minute window, chat:write requested when the bot needs chat:write.public, or channels:history requested for a private channel that wanted groups:history.

None of that is cryptography. It is reading the scope reference before writing the authorize URL, and handling three error strings: missing_scope, not_in_channel, and a 429 with Retry-After.

Overview

Take a release-notes bot. It reads a private engineering channel, posts a summary into a public channel it has never joined, and renames itself per repo. Three sentences, and each one is a different scope: groups:history for the private channel, chat:write.public for the channel it is not in, chat:write.customize for the name. Ask for chat:write and channels:history instead, which is what most first drafts do, and all three fail with errors that do not obviously point at scopes.

That is the real difficulty with Slack. The Web API is forgiving and the Python SDK is thin. The cost sits in decisions made before the first request: which token type, which scopes, which transport, and whether the app opted into rotation. We walk the exchange once in full, then spend the rest of the post on the decisions, because the decisions are what send you back to a reinstall.

Refresh mechanics and invalid_grant diagnosis are covered separately in OAuth refresh tokens, and storage in how to store OAuth tokens securely.

The code is not the token. What lands on your redirect URI is good for exactly one exchange, and Slack's docs put the window at ten minutes. Treat it like a claim ticket.

The Exchange, Step by Step

Slack uses the standard OAuth 2.0 authorization code grant. Four things happen in order.

First, send the user to https://slack.com/oauth/v2/authorize with client_id, scope (a comma separated list of bot scopes), optionally user_scope for user-token scopes, redirect_uri, and a state value you generated. Bot scopes and user scopes are separate parameters, and that split is the first place people lose an afternoon.

Second, Slack redirects back to your redirect_uri with code and the state echoed. Slack's guidance on that comparison is blunt: "If it doesn't match what you sent, consider the authorization a forgery." The redirect_uri has rules too. It must be HTTPS, must match a Redirect URL configured in App Management, must be identical in both the authorize and the access steps when you have more than one configured, and cannot contain an anchor.

Third, your backend POSTs to https://slack.com/api/oauth.v2.access with code, client_id, client_secret and redirect_uri. This is server to server, because it carries the client secret.

Fourth, you store what comes back. The response carries access_token, token_type, scope, bot_user_id, app_id, team, authed_user, is_enterprise_install, and, if your app enabled rotation, refresh_token and expires_in. The user token, when you asked for user scopes, is nested inside authed_user, not at the top level.

Your backendSlackBrowserYour backendSlackBrowserstate_store.issue()GET /oauth/v2/authorize (client_id, scope, user_scope, state)consent screen, then redirect ?code=&state=code + statestate_store.consume(state) or rejectPOST /api/oauth.v2.access (code, client_id, client_secret)access_token, authed_user, team, bot_user_idpersist installation keyed by team_id (+ enterprise_id)

Figure 1 — The v2 flow. The two lines that get skipped are issuing and consuming state, and the last one, persisting the installation under a key that survives an Enterprise Grid install.

If the exchange fails, Slack names the reason: invalid_code for a code that is wrong or already spent, bad_redirect_uri for a mismatch, bad_client_secret, invalid_grant_type, and invalid_refresh_token. The one that confuses people is invalid_code on a code that looks fine. Almost always the handler queued the exchange, retried after a timeout, or sat behind a slow page render.

The Redirect Handler in Python

slack_sdk ships the pieces for this, and they are worth using rather than reimplementing. Start with the authorize URL and a state store.

python
import os
from flask import Flask, request, redirect
from slack_sdk.oauth import AuthorizeUrlGenerator
from slack_sdk.oauth.state_store import FileOAuthStateStore
from slack_sdk.oauth.installation_store import FileInstallationStore, Installation
from slack_sdk.web import WebClient

app = Flask(__name__)
state_store = FileOAuthStateStore(expiration_seconds=300, base_dir="./data")
installation_store = FileInstallationStore(base_dir="./data")

authorize_url_generator = AuthorizeUrlGenerator(
    client_id=os.environ["SLACK_CLIENT_ID"],
    scopes=["chat:write", "chat:write.public", "groups:history"],
    user_scopes=[],
)

@app.route("/slack/install")
def install():
    return redirect(authorize_url_generator.generate(state_store.issue()))

FileOAuthStateStore is fine for a single box and the wrong answer for anything with more than one process; swap the base class for a Redis or Postgres implementation when you scale out. The expiration_seconds=300 is doing real work: an unbounded state store is a replay surface.

Then the callback. Note that consume is called before anything touches the code.

python
@app.route("/slack/oauth/callback")
def callback():
    if "error" in request.args:
        return f"denied: {request.args['error']}", 400
    if not state_store.consume(request.args.get("state", "")):
        return "state mismatch", 401          # treat as a forgery, per Slack

    client = WebClient()                       # no token yet, by design
    resp = client.oauth_v2_access(
        client_id=os.environ["SLACK_CLIENT_ID"],
        client_secret=os.environ["SLACK_CLIENT_SECRET"],
        code=request.args["code"],
        redirect_uri=os.environ.get("SLACK_REDIRECT_URI"),
    )
    installation_store.save(_to_installation(resp))
    return "installed", 200

The last piece is turning the response into something storable. Two fields here are easy to get wrong: the bot token is the top-level access_token, and the user token, if you asked for user scopes, lives under authed_user.

python
def _to_installation(resp) -> Installation:
    authed = resp.get("authed_user") or {}
    return Installation(
        app_id=resp.get("app_id"),
        enterprise_id=(resp.get("enterprise") or {}).get("id"),
        team_id=(resp.get("team") or {}).get("id"),
        bot_token=resp.get("access_token"),
        bot_id=resp.get("bot_user_id"),
        bot_scopes=(resp.get("scope") or "").split(","),
        user_id=authed.get("id"),
        user_token=authed.get("access_token"),
        user_scopes=(authed.get("scope") or "").split(","),
        is_enterprise_install=resp.get("is_enterprise_install", False),
    )

Key the record on team_id plus enterprise_id, not on the installing user. An Enterprise Grid install can return is_enterprise_install: true with no single team, and a user-keyed row has nowhere to put it. Slack's security guidance on the stored result is the obvious one, stated because people do not follow it: "Never hardcode them directly into your application's source code or store them in non-secure locations, like in public repositories."

Bot Tokens and User Tokens

This is the decision that matters, and it is not a preference.

Bot token (xoxb-)User token (xoxp-)
RepresentsThe app. Slack's docs say bot tokens are "not tied to a user's identity"A workspace member, "issued for the user who installed the app and for users who authenticate the app"
SeesOnly conversations the bot has been added to"the same access a user has to a workspace", including their DMs and private channels
Message authorYour app's name and iconThe person, as if they typed it
Requested viascope on the authorize URLuser_scope on the authorize URL
Found in the response ataccess_tokenauthed_user.access_token
When the installing user leavesSurvives. Slack notes the app "stay[s] installed even when an installing user is deactivated"Goes with them
Good forAlmost everythingActing as a specific human, or reading what only they can read

Table 1 — Bot versus user tokens. The bottom two rows decide most cases on their own.

Slack's own recommendation is unambiguous: "using bot tokens is usually for the best." We would follow it and treat a user token as something you reach for with a reason you can write down, because a user token is the one that turns an ordinary offboarding into a broken integration.

The practical test is whether a human should appear to have done the thing. A bot posting a deploy summary is a bot. A workflow that files a leave request on someone's behalf is that person, and a bot token cannot impersonate them: chat:write.customize changes the display name and avatar on a message, but the message is still from your app.

A user token also fails in a way nobody plans for. If the person who installed the app leaves and their account is deactivated, every call made with their token stops working, and the first symptom is usually a silent integration rather than an alert. Slack's phrasing of the advantage is the whole argument for the other column: a bot acting independently lets your app "stay installed even when an installing user is deactivated". If you do hold user tokens, treat the identity provider's offboarding event as an integration event too, because nothing on Slack's side will tell you.

There is a third type worth naming so you do not confuse it. App-level tokens start with xapp- and "represent your app across organizations". They are not workspace credentials and they cannot call most Web API methods. Socket Mode is what they are for.

Pick the token type from the audit question, not the API question. Ask who should be recorded as having done this. The scopes follow from that answer, and reversing it later means a reinstall.

Token Rotation

By default a Slack bot token does not expire, which is convenient until you notice it means a token leaked six months ago still works. Apps can opt into token rotation, and the shape changes completely.

With rotation on, the access token "expires every 12 hours", which is expires_in of 43200, and you get a refresh_token alongside it. The refresh is the same endpoint with a different grant type.

python
def refresh(installation) -> dict:
    return WebClient().oauth_v2_access(
        client_id=os.environ["SLACK_CLIENT_ID"],
        client_secret=os.environ["SLACK_CLIENT_SECRET"],
        grant_type="refresh_token",
        refresh_token=installation.bot_refresh_token,
    ).data

Three details from Slack's rotation docs are worth pinning down. Refresh tokens "are designed to be used once" and the one you sent "is revoked after a short grace period", so persist the new pair before you use it. Refreshing in a loop is throttled: "If you refresh your credentials repeatedly before expiration...we will enforce a limit of 2 active tokens." And migrating an existing app uses oauth.v2.exchange, which is one-way per token, since "You won't be able to exchange the same access token for a refresh token more than once."

Schedule the refresh comfortably before the 12-hour mark rather than reacting to a failure. Reacting means discovering expiry in the middle of whatever the app was doing.

Scopes That Cost a Day

Three scope pairs account for most of the lost time.

chat:write vs chat:write.public vs chat:write.customize. chat:write posts to channels the bot is already in. chat:write.public grants the ability to "Send messages to channels your Slack app isn't a member of", which is the one you want for a notifier that should not need an invite to every channel. The alternative is channels:join and a conversations.join call, which only works for public channels. chat:write.customize is what lets chat.postMessage set username, icon_url and icon_emoji. Both of the extended scopes are bot-token only, and both require chat:write as well. Without chat:write.public you get not_in_channel, which reads like a membership problem rather than a scope problem, and that is why it eats an afternoon.

History scopes are per conversation type. conversations.history requires channels:history, groups:history, im:history and mpim:history, and they map to public channels, private channels, DMs and group DMs respectively. Requesting channels:history and pointing the app at a private channel returns missing_scope. The method name gives no hint that four scopes hide behind it.

Read scopes are coarser than you expect. users.list needs users:read and hands back the workspace directory, not the one person you wanted to resolve. conversations.list needs channels:read, groups:read, im:read and mpim:read. Ask for the narrowest set that covers the conversation types you actually touch.

One habit removes most of this. The oauth.v2.access response includes a scope field listing what was actually granted, and the user token's own scopes come back under authed_user. Compare both against what you asked for at install time and store the result. A workspace that installed your app eight months ago has the scope set from eight months ago, and a feature you shipped since then will fail there and nowhere else, which is a miserable bug to reproduce from a support ticket.

When a call fails, Slack answers HTTP 200 with a JSON body whose ok is false, so a client that only checks status codes will sail past it. missing_scope is the one to catch explicitly, because the fix is never in your code: it is a new authorize round trip. Slack's install docs say scopes accumulate, and "any new scopes you request will be added to that initial set", so you send the user through the authorize URL again with the fuller list rather than uninstalling. The token you are already holding does not gain the scope until they do.

python
from slack_sdk.errors import SlackApiError

try:
    client.chat_postMessage(channel="C0123456789", text="build 4821 is green")
except SlackApiError as exc:
    err = exc.response["error"]
    if err == "missing_scope":
        queue_reauthorization(team_id)          # needs a new consent round trip
    elif err in ("not_in_channel", "channel_not_found"):
        client.conversations_join(channel="C0123456789")   # needs channels:join
    elif err == "ratelimited":
        time.sleep(int(exc.response.headers.get("Retry-After", "30")))
    else:
        raise

Rate Limits, and What Retry-After Obliges

Slack's limits are per method, so a burst on one does not spend another's budget. The tiers are published with numbers: Tier 1 is "1+ per minute", Tier 2 "20+ per minute", Tier 3 "50+ per minute", Tier 4 "100+ per minute", and a Special tier where "Rate limiting conditions are unique." users.list and conversations.list are Tier 2. conversations.history is Tier 3.

chat.postMessage sits in the Special tier, and the number is the one to design around: apps "may post no more than one message per second per channel", with short bursts tolerated above that.

Exceed a limit and Slack returns "a HTTP 429 Too Many Requests error, and a Retry-After HTTP header containing the number of seconds until you can retry." Honour that header exactly. Do not substitute your own backoff curve, and do not retry sooner because the number looks large.

One change is easy to miss and it is severe. As of 29 May 2025, for new apps distributed commercially outside the Slack Marketplace, conversations.history is rate limited to 1 request per minute, and the limit parameter's maximum and default drop to 15 objects. That is a different product than Tier 3 with 1,000-message pages. If you are building a history-reading app today and you are not going through Marketplace review, design for it now rather than discovering it at scale.

Socket Mode, Events API, or Plain Web API

Three ways to talk to Slack, and most confusion comes from treating them as alternatives when two of them are the same thing with different plumbing.

No

Yes

Yes

No

Does the app need to react to
things happening in Slack?

Web API only

Can you expose a
public HTTPS URL?

Events API over HTTP
verify signature, ack in 3s

Socket Mode
app-level token, WebSocket

Not allowed in the
public Slack Marketplace

Figure 2 — Choosing a transport. The Marketplace restriction on Socket Mode is the constraint that most often decides it, and it decides it late if nobody checked.

The Web API is the outbound direction: you call Slack. Every example above uses it, and if your app only posts messages and reads on a schedule, you need nothing else.

The Events API is the inbound direction. As the docs put it, "When you use the Events API, Slack calls you." Over HTTP that means a public Request URL, a verified signature using the X-Slack-Signature and X-Slack-Request-Timestamp headers with a five-minute replay window, and an acknowledgement: "Your app should respond to the event request with an HTTP 2xx within three seconds." Miss it and Slack retries three times, immediately, after a minute, and after five minutes, tagging each attempt with x-slack-retry-num. Do the work after you acknowledge, not before, or you will process the same event four times.

Socket Mode is the same Events API delivered over a WebSocket, so you need no public URL. It uses an app-level token and apps.connections.open, supports up to 10 concurrent connections, and carries one restriction that decides the choice for commercial apps: "Apps using Socket Mode are not currently allowed in the public Slack Marketplace." Use it for internal tools and local development. Do not build a product on it and plan to list.

Where Fabriq Fits

Agentic Fabriq's Slack integration holds each user's credential in a vault and attaches it server-side at the moment of the call, so an agent posting to a channel never handles the xoxb- or xoxp- string itself. Connections are per user, refresh runs automatically with last-used tracking, and the tool list an agent actually gets is the intersection of the user's scopes and the agent's.

What it does not do is revoke anything on Slack's side when someone disconnects. Clearing a vault copy is not the same act as invalidating the install, so auth.revoke is still a call you make.

Frequently Asked Questions

Bot token or user token for a Slack app? Bot token, unless a human needs to appear as the author or you need to read something only that person can see. Slack's own docs say "using bot tokens is usually for the best", largely because the app stays installed when the installing user is deactivated.

Where is the user token in the oauth.v2.access response? Under authed_user.access_token, not at the top level. The top-level access_token is the bot token. Reading the wrong one is the most common first-install bug.

Why do I get not_in_channel when I have chat:write? Because chat:write only covers channels the bot has joined. Either add chat:write.public, which grants sending "to channels your Slack app isn't a member of", or add channels:join and call conversations.join first. chat:write.public also requires chat:write.

Why does conversations.history return missing_scope? Because the scope depends on the conversation type. Public channels need channels:history, private channels groups:history, DMs im:history, group DMs mpim:history. The method needs whichever matches the channel you passed.

Do I need to reinstall the app to add a scope? No uninstall is needed. Slack's docs say "any new scopes you request will be added to that initial set", so you send the user through the authorize URL again with the wider list. Until they complete it, your existing token still lacks the scope.

How long is the Slack OAuth code valid? Ten minutes, and it is single use. If your handler queues the exchange or retries after a timeout, expect invalid_code on the second attempt.

Do Slack tokens expire? Not by default. If your app enables token rotation, the access token expires after 12 hours (expires_in 43200) and the refresh token is designed to be used once.

What does Retry-After oblige me to do? Wait that many seconds. Slack returns it on HTTP 429 as "the number of seconds until you can retry", and it is not advisory. slack_sdk can handle this for you with a rate-limit retry handler, but only if you install one.

Socket Mode or the Events API? Socket Mode if you cannot expose a public HTTPS endpoint, and the app is internal. The Events API over HTTP if you are building anything you intend to distribute, because apps using Socket Mode "are not currently allowed in the public Slack Marketplace".

Conclusion

The exchange is four steps and slack_sdk gives you three of them. What is left after that is a set of decisions you make by reading reference pages: bot or user, which of the four history scopes, whether chat:write.public applies, whether rotation is on, and which transport survives the Marketplace review you may want in a year.

Write the scope list last, after you have written down what the app does in plain sentences and mapped each sentence to a scope. That ordering catches chat:write.public and groups:history before an install does, and it is the cheapest review step available.

We think the Slack-specific part of this is small and the transferable part is large. Separate bot and user identity, name the scopes from the behaviour, honour Retry-After, and the next provider you connect will look familiar.

Sources

All URLs read 2026-09-29.