Slack oauth.v2.access: The Install Flow and Every Error We Got
We ran Slack's Add to Slack flow against a 34-line Python callback on localhost, then broke each step: a reused code, a typo, a wrong secret, a wrong redirect_uri, a cancelled consent screen and a code we held for 11 minutes.
On this page
oauth.v2.access is the Slack API method that turns the temporary code Slack sends to your redirect URL into tokens: a bot token (xoxb-) for the app, and a user token (xoxp-) when you also asked for user scopes. You call it from your server with the app's client_id, client_secret, the code, and the same redirect_uri you used in the authorize link. On 2 October 2026 we ran the whole Add to Slack flow in our own test workspace with a small Python callback on localhost, then broke each step on purpose. Every response below is one we got.
The flow in three steps
- Send the person to
https://slack.com/oauth/v2/authorizewithclient_id,scope(bot scopes),user_scope(user scopes, if any) andredirect_uri. - They pick a workspace and click Allow. Slack redirects the browser to your
redirect_uriwith?code=.... - Your server posts that code to
oauth.v2.accessand stores the tokens from the response.
This is the consent screen our authorize link opened, for an app with 10 bot scopes and 2 user scopes:
The redirect URL has to be listed under OAuth & Permissions > Redirect URLs. What Slack accepts there, including http://localhost, is on our redirect URL page.
A callback that exchanges the code
This is the server we ran on localhost:8853. It uses slack_sdk 3.45.0 and logs each response to a file. The HOLD_FILE lines let us keep a code without exchanging it, for the expiry test below:
import json, os, sys, time, urllib.parse
from http.server import BaseHTTPRequestHandler, HTTPServer
from slack_sdk import WebClient
from slack_sdk.errors import SlackApiError
CLIENT_ID = os.environ["SLACK_CLIENT_ID"]
CLIENT_SECRET = os.environ["SLACK_CLIENT_SECRET"]
REDIRECT_URI = "http://localhost:8853/callback"
HOLD = os.environ.get("HOLD_FILE", "") # lab only: keep the code, do not exchange it
OUT = os.environ.get("CB_LOG", "callback.jsonl")
class Callback(BaseHTTPRequestHandler):
def do_GET(self):
url = urllib.parse.urlparse(self.path)
q = dict(urllib.parse.parse_qsl(url.query))
rec = {"at": time.time(), "path": url.path, "query_keys": sorted(q), "code": q.get("code"), "error": q.get("error")}
if "code" in q and not (HOLD and os.path.exists(HOLD)):
try:
resp = WebClient().oauth_v2_access(client_id=CLIENT_ID, client_secret=CLIENT_SECRET,
code=q["code"], redirect_uri=REDIRECT_URI)
rec["response"] = resp.data
except SlackApiError as e:
rec["response"] = e.response.data
with open(OUT, "a") as f:
f.write(json.dumps(rec) + "\n")
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.end_headers()
ok = rec.get("response", {}).get("ok")
self.wfile.write(f"callback received; exchange ok={ok}".encode())
def log_message(self, *a): pass
HTTPServer(("127.0.0.1", 8853), Callback).serve_forever()
After we clicked Allow, the callback received the code and Slack answered the exchange with this (tokens shortened here; a real response has the full strings):
{
"ok": true,
"app_id": "A0C605Y7BQD",
"authed_user": {
"id": "U0B7L4YK420",
"scope": "users:read,chat:write",
"access_token": "xoxp-…redacted",
"token_type": "user"
},
"scope": "chat:write,commands,channels:manage,groups:write,channels:join,users:read,channels:read,groups:read,channels:history,groups:history",
"token_type": "bot",
"access_token": "xoxb-…redacted",
"bot_user_id": "U0C695L6YVA",
"team": {
"id": "T0B7JBCDKC1",
"name": "Slack"
},
"enterprise": null,
"is_enterprise_install": false
}
access_token at the top level is the bot token. The user token sits in authed_user.access_token, and it is only there when the authorize link asked for user_scope. bot_user_id is the bot's user ID; our bot token page covers what each token type can do.
We ran the flow ten more times with the same app. The three later exchanges we compared in full returned the same two token strings as the first one, and the first tokens still passed auth.test at the end. Two of those runs added scopes; they kept both strings and only changed the scope lists. Which bot scopes a method needs is in our 44-method scan.
Never appear "away" on Slack again
Cloud-based. No downloads. Works 24/7 even when your laptop is off.
Errors we reproduced
| What we changed | oauth.v2.access response |
|---|---|
| Sent a code we had already exchanged | {"ok": false, "error": "invalid_code"} |
| Exchanged a fresh code twice in a row | first ok, second invalid_code |
| Changed the last character of the code | invalid_code |
Wrong client_secret | bad_client_secret |
No client_secret at all | bad_client_secret |
Wrong client_id | invalid_client_id |
redirect_uri different from the authorize link | bad_redirect_uri |
The same code again with the right redirect_uri | invalid_code |
No redirect_uri in the exchange | ok |
| A code held 9 min 41 s | ok |
| A code held 10 min 15 s, and two held 11 min | internal_error |
Things to take from the table:
invalid_code, not code_already_used. We never saw code_already_used, even when we sent the same code twice within a second. If your logs show invalid_code right after a successful install, look for a second request: a page refresh on the callback URL or a retry in your HTTP client.bad_redirect_uri uses up the code. After it failed, the correct call with the same code returned invalid_code, so you need a new authorization to try again.internal_error at 10 min 15 s and at 11 minutes, which fits the 10 minutes in Slack's docs. Retrying the same code returned internal_error again. A fresh code exchanged a minute later worked, so it was not an outage. Exchange the code as soon as the callback receives it.redirect_uri worked in our test, where the app has one redirect URL. Sending it anyway is safer: when it is sent, it must match.Errors before the exchange
Some failures never reach oauth.v2.access. When we clicked Cancel on the consent screen, Slack redirected to our callback with no code:
/callback?error=access_denied&state=&error_description=The+user+has+denied+access+to+the+scope%28s%29+requested+by+the+client+application.
Handle error in the callback, or a person who clicks Cancel lands on a crash. And when the authorize link asked for a scope that does not exist (channels:write.bogus), Slack showed this page instead of the consent screen:
The page does not name the bad scope. Compare the scope and user_scope values in your link with the scope list in the app's settings, and check that you did not put a user scope in scope. After the install, a call that needs a scope you did not request fails with missing_scope.
FAQ
Do Slack OAuth tokens expire?
The tokens from this flow did not expire during our test: the first bot and user tokens still worked after ten more authorizations. Slack's docs say they expire only when the app turns on token rotation, which adds refresh_token and expires_in to this response.
Do I need oauth.v2.access for an app in my own workspace?
No. For an internal app, Install to Workspace on the app's settings page shows the bot token, so you can skip the flow. You need it when other workspaces, or other people in yours, install the app. Other workspaces can install it only after you turn on public distribution, which has its own checks; see Slack Marketplace requirements.
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
How to See All Your DMs in Slack, Including the Hidden Ones
The DMs tab lists your direct messages with people. App DMs, Slackbot and DMs with deleted accounts sit elsewhere. We compared the tab with the full list the Slack API returned.
Slack Images Not Showing? 5 Causes We Reproduced
We uploaded images to a Slack test channel and then switched each image setting off, ran /collapse, and posted from a bot that was not in the channel. Five causes, each reproduced.
How to Message Yourself on Slack (and What It Notifies)
Your own name under Direct messages opens a private DM with yourself. We sent it a note, a file, a scheduled message and a reminder, and checked which ones raised a badge.