Back to Blog
Guide

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.

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

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/authorize with client_id, scope (bot scopes), user_scope (user scopes, if any) and redirect_uri.
  • They pick a workspace and click Allow. Slack redirects the browser to your redirect_uri with ?code=....
  • Your server posts that code to oauth.v2.access and 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:

    Slack's consent page:

    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.

Never appear "away" on Slack again

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

Errors we reproduced

What we changedoauth.v2.access response
Sent a code we had already exchanged{"ok": false, "error": "invalid_code"}
Exchanged a fresh code twice in a rowfirst ok, second invalid_code
Changed the last character of the codeinvalid_code
Wrong client_secretbad_client_secret
No client_secret at allbad_client_secret
Wrong client_idinvalid_client_id
redirect_uri different from the authorize linkbad_redirect_uri
The same code again with the right redirect_uriinvalid_code
No redirect_uri in the exchangeok
A code held 9 min 41 sok
A code held 10 min 15 s, and two held 11 mininternal_error

Things to take from the table:

  • • A reused code gave 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.
  • • An expired code did not say so. Ours worked at 9 min 41 s and returned 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.
  • • Leaving out 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:

    Slack error page:

    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.

    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

    Slack API Pagination: next_cursor, the Last Page and invalid_cursor, Tested

    Slack paginates list methods with a cursor: pass response_metadata.next_cursor back as cursor until it is empty. We paged 27 real messages on 2 October 2026 and broke the cursor five ways to see which ones fail.

    Slack Green Team
    Guide

    Slack RTM API Deprecated: What rtm.connect Returns for a New App, and the Socket Mode Fix

    A Slack app created today cannot use the RTM API. We called rtm.connect and rtm.start with every token a new app gets on 2 October 2026, tried to request the rtm:stream scope, and ran the same bot over Socket Mode.

    Slack Green Team
    Guide

    Slack Bot Icon and Name Per Message: icon_emoji, icon_url and username, Tested

    icon_emoji, icon_url and username only work with the chat:write.customize scope, and Slack ignores them silently without it. We tested every case on 2 October 2026, plus webhooks and the app icon upload limits.

    Slack Green Team