Skip to content

Diagnostic Slack

bad_redirect_uri: the callback URL is not on the allow list

The install works. You have run it forty times on your laptop, and every one of them came back with a workspace, a bot user and a token. Then the app goes to production behind a real domain, the first customer clicks Add to Slack, and the browser lands on a Slack error page or your callback logs {"ok": false, "error": "bad_redirect_uri"} and nothing else.

Nothing about your code changed. What changed is one string. Slack holds the redirect_uri you present at the exchange against the Redirect URLs configured on the app, and the comparison is unforgiving about the parts of a URL people treat as decoration: the scheme, the port, the path, the trailing slash, and whether the parameter was sent at all.

Read-only token Python and Node.js Tests included
Paying with a card
Photo by Nathan Dumlao on Unsplash
The short answer

bad_redirect_uri means the redirect_uri your server sent to oauth.v2.access is not covered by any entry under OAuth & Permissions → Redirect URLs. Slack matches from the configured entry forward, so a configured https://app.example.com/slack covers https://app.example.com/slack/callback — but only after the scheme, the host and the port have matched exactly. http against https is a miss. :8080 against nothing is a miss. /slack/callback/ against a configured /slack/callback is a match, and the reverse is not.

There is a second rule that catches the installs which worked yesterday. redirect_uri must be present and identical in both legs of the flow, or absent from both. Send it on the authorize URL and omit it at the exchange, or the other way round, and Slack refuses even though the URL itself is on the list. Omitting it from both is tolerated only while exactly one Redirect URL is configured — so adding a second one breaks an app that never sent the parameter, at a moment unconnected to any deploy.

None of this needs a live install to diagnose, and the temptation to get one is the trap. Do not call oauth.v2.access to see what it says. That call redeems an authorization code: the code is single use, it expires in minutes, and a successful exchange installs the app and mints a token. A diagnostic that runs it consumes the evidence and changes the thing it was measuring. The script below compares strings you already have, and makes exactly one read: the manifest.

The problem in plain words

Every part of this is invisible in normal use, because the flow that fails is the one flow you cannot step through in a debugger. The user leaves your site, does something on Slack's domain, and comes back with a code that is valid for one attempt. Whatever is wrong has already happened by the time your handler runs, and the only thing your handler gets to see is the refusal.

The commonest shape is an environment that grew a new URL. Development ran on http://localhost:3000/slack/callback, which somebody added to the Redirect URLs list on day one and never removed. Staging arrived on a preview domain. Production arrived on the real one, behind a load balancer that terminates TLS, and the application — which now sees plain HTTP on the inside — builds its callback URL from the request it received and confidently sends http://app.example.com/slack/callback to an allow list containing only the https form. The proxy header that would have told it otherwise was never trusted.

The second shape is punctuation. A trailing slash appended by a framework's router. A port spelled out that the browser omits. A host with a capital letter, which does not matter, next to a path with one, which does. An entry pasted into the Slack form with a stray space at the end. These are all sub-visual differences between two strings that a human comparing them side by side will read as identical, which is exactly why the comparison belongs in a script.

The third shape is asymmetry, and it is the one that fails without warning long after the code was written. A library builds the authorize URL for you and includes redirect_uri automatically; your own exchange call, hand-rolled, does not. While one Redirect URL was configured, Slack filled in the blank and everything worked. The day somebody adds a second environment to the list, the blank becomes ambiguous and every install starts failing — on a change nobody associates with the install flow, made in a web form rather than in the repository.

And a note on the error itself, because it is not always the one you get. Slack's OAuth surface returns HTTP 200 with ok: false like the rest of the Web API, so a client checking the status code sees success and reads a token field that is not there. Some redirect problems never reach your server at all: the user is bounced to a Slack error page mid-install and your logs contain nothing, because nothing was ever sent to you.

Add to Slackthe authorize URLis builtThe userconsentsscopes shown andacceptedBack to thecallbackwith a code in thequeryExchange thecoderedirect_uri sentas httpbad_redirect_uriand the code isnow spent
Four steps succeed and the fifth cannot be retried. The code is single use, so the evidence expires with the attempt and the user has to start the install again from the beginning.

Why it happens

The exchange is a write, and this is the one place in this section where that word has teeth. oauth.v2.access does not report on an installation; it performs one. It redeems a code that can only be redeemed once, and on success it creates a token and an install record. Running it as a test either burns the user's code so they have to start over, or quietly installs your app into a workspace with whatever scopes were requested. The script here never sends a code anywhere.

Comparing components beats comparing strings, because the answer has to name the difference. “This callback is not on the list” sends somebody to squint at two URLs. “This callback matches the third configured entry in every part except the scheme” is a repair. So the check splits both sides into scheme, host, port and path, normalises only the parts Slack treats as case-insensitive, and reports which component disagrees.

The trailing slash is asymmetric and the script has to be too. Prefix matching runs from the configured entry forward, so a configured path is allowed to be shorter than the deployed one and never longer. That means /slack covers /slack/callback, and a configured /slack/callback/ does not cover /slack/callback. Treating the slash as noise on both sides would produce a confident pass on an install that fails.

The presence rule is worth a check of its own, because it has no error message that names it. Slack answers bad_redirect_uri whether the URL is wrong or merely inconsistent between the two legs, so the error cannot distinguish the two cases. The script asks for both strings and classifies the pair, which turns an identical error message into two different repairs.

Nothing here reads a secret, and that is a design constraint rather than a courtesy. The client secret is required to complete an exchange and is required for nothing else in this diagnosis. A redirect URL is not sensitive: it is in the browser's address bar during every install. So the script reads URLs, prints URLs, and has no code path that touches SLACK_CLIENT_SECRET at all.

This is the install flow, before any token exists. If your exchange succeeds and the token you get back stops working twelve hours later, or a refresh fails, that is the token lifecycle and it is a different note with a different check. The boundary is clean: this one ends the moment oauth.v2.access returns ok: true.

The fix, as a flow

The red arrow is the last one, which is rare in this section and is the whole character of the failure. Everything before it worked: the authorize URL was built, the user consented, Slack sent the browser back to your callback with a code in hand. The refusal lands on the one step that cannot be attempted twice, because the code it was carrying is single use and is now spent. That is also why the fix branch sorts URL components rather than error strings. The answer has to name which part of the URL disagrees, and it has to reach that answer without redeeming anything.

Deployed against configuredscheme, host, port, pathSame but for the schemeTLS ends at the load balancerSame but for the porta port that exists in a containerA slash on the wrong sidematching runs forward onlySent in one leg onlypresent in both, or in neitherCovered, and both agreethe install completes
Whole-string comparison answers yes or no. Component comparison answers which part, which is the difference between a squint and a repair. Nothing is transmitted and no code is redeemed to reach it.

How to fix it

Read the configured list from the manifest, or paste it in

apps.manifest.export returns oauth_config.redirect_urls. It needs an app configuration token, which is a different credential class from your bot token — if you do not have one, pass --configured with the entries copied from the OAuth & Permissions page and every other check still runs. The list is the allow list, and it is the only side of the comparison Slack owns.

Say what is wrong with each configured entry on its own

redirect_concerns reads one entry and reports insecure-scheme, loopback, has-query, has-fragment, trailing-slash, explicit-default-port and userinfo. A localhost entry on a distributed app is not an error today and is a support ticket eventually; a query string is a misunderstanding, because Slack does not match on it and state is where per-install data belongs.

Hold every deployed callback against the list

match_callback answers exact, prefix, near-miss, no-match or unusable. The verdict that earns its keep is near-miss: it names the closest configured entry and the single component that differs, which is almost always the scheme behind a TLS-terminating proxy or a port that only exists inside a container.

Check the parameter is present in both legs or neither

parameter_symmetry takes the redirect_uri your authorize URL carries and the one your exchange sends, plus how many URLs are configured, and returns identical, authorize-only, exchange-only, different, both-absent or ambiguous. ambiguous is the time bomb: neither leg sends it and more than one URL is configured.

Trust the proxy header, or stop building the URL from the request

The repair the script prints for near-miss on the scheme is not a Slack change. It is either configuring your framework to honour X-Forwarded-Proto, or — better — putting the callback URL in configuration as a literal string, the same string that is on the allow list, so that the two can only ever drift on purpose.

Put the list in the manifest so environments cannot drift

Every entry belongs under oauth_config.redirect_urls in a manifest kept in the repository. A Redirect URL added through the web form exists in exactly one place, is invisible to code review, and is the reason an app can break on a Tuesday afternoon with no deploy attached to it.

How to check it worked

Fix the string, redeploy, and run it again. The line to read is the last callback row: it should say exact, and the symmetry row should say identical.

python3 slack_oauth_redirect.py --app-id A05NW7XQ1 \
  --callbacks https://app.northwind.example/slack/callback \
  --authorize-redirect https://app.northwind.example/slack/callback \
  --exchange-redirect http://app.northwind.example/slack/callback
# manifest   ok             3 configured redirect url(s) for A05NW7XQ1
# configured 1              http://localhost:3000/slack/callback
#            loopback       an install that lands here only works on the machine
#                           that ran it; remove it before the app is distributed
# configured 2              https://staging.northwind.example/slack/callback/
#            trailing-slash matching runs from the configured entry forward, so this
#                           entry does not cover the same path without the slash
# configured 3              https://app.northwind.example/slack/callback
# callback   near-miss      https://app.northwind.example/slack/callback matches
#                           configured entry 3 in every part except the scheme
# symmetry   different      the authorize url and the exchange send different strings
# verdict    2 finding(s)
#   repair: send the identical redirect_uri in both the authorize url and the
#           oauth.v2.access exchange, or omit it from both
#   repair: build the callback url from configuration, not from the inbound
#           request, or honour X-Forwarded-Proto behind the load balancer
#   note:   no code was redeemed to establish this; oauth.v2.access is a write

The full code

One network call, to apps.manifest.export, and it is optional. Everything that decides anything is a pure function over URL strings: normalise_url splits a callback into the four parts Slack compares and lowercases only the two that are case-insensitive, redirect_concerns reads one configured entry on its own terms, match_callback finds the closest entry and names the component that differs, and parameter_symmetry applies the present-in-both-or-neither rule. There is no code path that reads a client secret and none that redeems a code.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_oauth_redirect.py
"""Compare the callback URLs you deploy against the ones Slack will accept.

Read only, and one method is refused on principle. oauth.v2.access redeems an
authorization code: the code is single use, it expires in minutes, and a
successful exchange installs the app and mints a token. A diagnostic that
"just tries it" consumes the evidence and performs the installation it was
asked to explain. Nothing here sends a code anywhere.

At most one GET is made, to apps.manifest.export, using an app configuration
token. Without one, pass --configured with the entries from the OAuth &
Permissions page and every other check still runs. No client secret is read,
printed or transmitted by any path in this file.
"""
import argparse
import logging
import os
import sys
from urllib.parse import urlsplit

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_oauth_redirect")

API = "https://slack.com/api/"

# Made explicit during normalisation so https://x and https://x:443 compare
# equal, which they do to Slack and do not to a string comparison.
DEFAULT_PORTS = {"http": "80", "https": "443"}

# Fine on a laptop, a support ticket in a distributed app: an install that
# lands on one of these only ever completes on the machine that started it.
LOCAL_HOSTS = ("localhost", "127.0.0.1", "0.0.0.0", "::1", "host.docker.internal")

# The order matters: the component reported as the difference is the first one
# that disagrees, and a scheme mismatch explains a port mismatch rather than
# the other way round.
COMPONENTS = ("scheme", "host", "port", "path")


def _difference(got, want):
    """Which components disagree? Pure, and one subsumption is applied.

    Changing the scheme changes the default port with it, so http against
    https would otherwise be reported as two differences and never as a near
    miss - which is precisely the case this check exists to name. When both
    sides are sitting on the default port for their own scheme, the port is
    not an independent disagreement.
    """
    diff = [k for k in COMPONENTS if got[k] != want[k]]
    if ("scheme" in diff and "port" in diff
            and got["port"] == DEFAULT_PORTS.get(got["scheme"])
            and want["port"] == DEFAULT_PORTS.get(want["scheme"])):
        diff.remove("port")
    return diff


def normalise_url(url):
    """Split a callback URL into the four parts Slack compares. Pure.

    Returns (parts, notes). The scheme and the host are lowercased because they
    are case insensitive; the path is left exactly as written because it is
    not, and because a trailing slash is a real difference rather than a
    typographic one. The port is made explicit so that the two spellings of the
    default port stop being different strings.
    """
    text = str(url or "").strip()
    notes = []
    empty = {"scheme": "", "host": "", "port": "", "path": ""}
    if not text:
        return (empty, ["empty"])
    if "://" not in text:
        notes.append("no-scheme")
        text = "https://" + text
    try:
        split = urlsplit(text)
        port = split.port
    except ValueError:
        return (empty, ["unparseable"])
    scheme = (split.scheme or "").lower()
    host = (split.hostname or "").lower()
    if not host:
        return (empty, notes + ["unparseable"])
    if split.username or split.password:
        notes.append("userinfo")
    if port is not None and str(port) == DEFAULT_PORTS.get(scheme):
        notes.append("explicit-default-port")
    if split.query:
        notes.append("has-query")
    if split.fragment:
        notes.append("has-fragment")
    parts = {
        "scheme": scheme,
        "host": host,
        "port": str(port) if port is not None else DEFAULT_PORTS.get(scheme, ""),
        "path": split.path or "/",
    }
    return (parts, notes)


def redirect_concerns(url):
    """Everything worth saying about one configured Redirect URL. Pure.

    Returns a list of (code, why). Nothing here is fatal on its own; these are
    the entries that will fail later, for somebody else, on a machine you do
    not have.
    """
    parts, notes = normalise_url(url)
    if "empty" in notes:
        return [("empty", "an empty entry matches nothing and hides the list length")]
    if "unparseable" in notes:
        return [("unusable", "this is not a URL Slack can match a callback against")]
    out = []
    if "no-scheme" in notes:
        out.append(("no-scheme", "the entry has no scheme, so it cannot match "
                                 "anything; write it out in full"))
    if parts["host"] in LOCAL_HOSTS:
        out.append(("loopback", "an install that lands here only works on the "
                                "machine that ran it; remove it before the app "
                                "is distributed"))
    elif parts["scheme"] == "http":
        out.append(("insecure-scheme", "the authorization code travels back on "
                                       "this URL in clear text, and a callback "
                                       "built as https will not match it"))
    if "has-query" in notes:
        out.append(("has-query", "Slack does not match on the query string; carry "
                                 "per-install data in the state parameter"))
    if "has-fragment" in notes:
        out.append(("has-fragment", "a fragment never reaches your server, so it "
                                    "cannot be part of a callback URL"))
    if "userinfo" in notes:
        out.append(("userinfo", "credentials embedded in the URL will not match "
                                "and should not exist"))
    if "explicit-default-port" in notes:
        out.append(("explicit-default-port", "the default port is spelled out here "
                                             "and omitted by every browser"))
    if parts["path"] != "/" and parts["path"].endswith("/"):
        out.append(("trailing-slash", "matching runs from this entry forward, so it "
                                      "does not cover the same path without the "
                                      "trailing slash"))
    return out


def match_callback(callback, configured):
    """Is this deployed callback covered by any configured entry? Pure.

    Returns (state, detail). States:

      exact      the callback is one of the configured entries, character for
                 character once case and the default port are settled.
      prefix     a configured entry is a prefix of it, with the scheme, host
                 and port identical. This is what Slack actually accepts.
      near-miss  the closest entry differs in exactly one component, and the
                 detail names which. Almost always the scheme.
      no-match   nothing on the list is close.
      unusable   the callback itself does not parse.
    """
    want, notes = normalise_url(callback)
    if "empty" in notes or "unparseable" in notes or not want["host"]:
        return ("unusable", "the callback URL given is not a URL")
    entries = []
    for raw in configured or []:
        got, gnotes = normalise_url(raw)
        if "empty" in gnotes or "unparseable" in gnotes or not got["host"]:
            continue
        entries.append((raw, got))
    if not entries:
        return ("no-match", "no usable entry is configured, so every install fails")
    for raw, got in entries:
        if got == want:
            return ("exact", "matches %s character for character" % raw)
    for raw, got in entries:
        same_origin = all(got[k] == want[k] for k in ("scheme", "host", "port"))
        if same_origin and want["path"].startswith(got["path"]):
            return ("prefix", "%s is a prefix of this callback, which Slack accepts; "
                              "the string sent as redirect_uri still has to be one "
                              "Slack can match" % raw)
    best, best_diff = None, None
    for raw, got in entries:
        diff = _difference(got, want)
        if best_diff is None or len(diff) < len(best_diff):
            best, best_diff = raw, diff
    if len(best_diff) == 1:
        return ("near-miss", "matches %s in every part except the %s"
                % (best, best_diff[0]))
    return ("no-match", "the closest entry is %s and it differs in the %s"
            % (best, ", ".join(best_diff)))


def parameter_symmetry(authorize_redirect, exchange_redirect, configured_count):
    """Apply the present-in-both-legs-or-neither rule. Pure.

    Returns (state, detail). Slack answers bad_redirect_uri for an inconsistent
    pair exactly as it does for a wrong URL, so the error cannot tell these
    apart and this function has to.

      identical      both legs send the same string. Correct.
      both-absent    neither leg sends it, and one URL is configured. Tolerated.
      ambiguous      neither leg sends it and more than one URL is configured.
                     This is the install that breaks when somebody edits a web
                     form months after the code was written.
      authorize-only / exchange-only / different are the three ways to send it
      inconsistently, and all three are refused.
    """
    left = str(authorize_redirect or "").strip()
    right = str(exchange_redirect or "").strip()
    if left and right:
        if normalise_url(left)[0] == normalise_url(right)[0]:
            return ("identical", "both legs send the same redirect_uri")
        return ("different", "the authorize URL sends %s and the exchange sends %s; "
                             "they must be the identical string" % (left, right))
    if left and not right:
        return ("authorize-only", "the authorize URL carries redirect_uri and the "
                                  "exchange omits it; send it in both or neither")
    if right and not left:
        return ("exchange-only", "the exchange sends redirect_uri and the authorize "
                                 "URL omits it; send it in both or neither")
    if int(configured_count or 0) > 1:
        return ("ambiguous", "neither leg sends redirect_uri and %d URLs are "
                             "configured, so Slack has nothing to disambiguate with"
                % int(configured_count))
    return ("both-absent", "neither leg sends redirect_uri and exactly one URL is "
                           "configured, which Slack tolerates; sending it in both "
                           "is still the safer shape")


def export_manifest(session, token, app_id):
    """One GET, with an app configuration token. Returns the parsed body."""
    r = session.get(API + "apps.manifest.export",
                    headers={"Authorization": "Bearer " + token},
                    params={"app_id": app_id}, timeout=30)
    try:
        return r.json()
    except ValueError:
        return {"ok": False, "error": "unparseable_body"}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--app-id", default="", help="the app id to export, A...")
    ap.add_argument("--config-token-env", default="SLACK_CONFIG_ACCESS_TOKEN",
                    help="environment variable holding an app configuration token")
    ap.add_argument("--configured", default="",
                    help="comma separated Redirect URLs, if you have no config token")
    ap.add_argument("--callbacks", default="",
                    help="comma separated callback URLs your deployments actually use")
    ap.add_argument("--authorize-redirect", default="",
                    help="the redirect_uri your authorize URL carries, if any")
    ap.add_argument("--exchange-redirect", default="",
                    help="the redirect_uri your oauth.v2.access call sends, if any")
    args = ap.parse_args()

    findings = 0
    configured = [u.strip() for u in args.configured.split(",") if u.strip()]
    token = os.environ.get(args.config_token_env)
    if args.app_id and token and not configured:
        body = export_manifest(requests.Session(), token, args.app_id)
        if body.get("ok") is not True:
            log.warning("manifest   unavailable    apps.manifest.export answered "
                        "ok: false, error=%s", body.get("error"))
        else:
            oauth_config = (body.get("manifest") or body).get("oauth_config") or {}
            configured = list(oauth_config.get("redirect_urls") or [])
            log.info("manifest   ok             %d configured redirect url(s) for %s",
                     len(configured), args.app_id)
    elif not configured:
        log.info("manifest   skipped        set %s, or pass --configured with the "
                 "entries from the OAuth & Permissions page", args.config_token_env)

    for i, entry in enumerate(configured, start=1):
        log.info("configured %-14d %s", i, entry)
        for code, why in redirect_concerns(entry):
            log.warning("           %-14s %s", code, why)
            findings += 1

    for callback in [u.strip() for u in args.callbacks.split(",") if u.strip()]:
        state, detail = match_callback(callback, configured)
        line = ("callback   %-14s %s -> %s", state, callback, detail)
        if state in ("exact", "prefix"):
            log.info(*line)
        else:
            log.warning(*line)
            findings += 1

    state, detail = parameter_symmetry(args.authorize_redirect, args.exchange_redirect,
                                       len(configured))
    if state in ("identical", "both-absent"):
        log.info("symmetry   %-14s %s", state, detail)
    else:
        log.warning("symmetry   %-14s %s", state, detail)
        findings += 1

    if not findings:
        log.info("verdict    clean          every deployed callback is covered and "
                 "both legs agree")
        return 0
    log.warning("verdict    %d finding(s)", findings)
    log.warning("  repair: add the exact callback URL for every environment under "
                "OAuth & Permissions, scheme and path included")
    log.warning("  repair: send the identical redirect_uri in both the authorize URL "
                "and the oauth.v2.access exchange, or omit it from both")
    log.warning("  repair: keep the list in the manifest under "
                "oauth_config.redirect_urls so environments cannot drift")
    log.warning("  note:   no code was redeemed to establish this; oauth.v2.access "
                "is a write and would install the app")
    return 1


if __name__ == "__main__":
    sys.exit(main())
slack-oauth-redirect.mjs
/**
 * Compare the callback URLs you deploy against the ones Slack will accept.
 *
 * Read only, and one method is refused on principle. oauth.v2.access redeems
 * an authorization code: the code is single use, it expires in minutes, and a
 * successful exchange installs the app and mints a token. Nothing here sends a
 * code anywhere.
 *
 * At most one GET is made, to apps.manifest.export, using an app configuration
 * token. No client secret is read, printed or transmitted by any path here.
 */

const API = 'https://slack.com/api/';

// Made explicit during normalisation so https://x and https://x:443 compare
// equal, which they do to Slack and do not to a string comparison.
export const DEFAULT_PORTS = { http: '80', https: '443' };

// Fine on a laptop, a support ticket in a distributed app.
export const LOCAL_HOSTS = ['localhost', '127.0.0.1', '0.0.0.0', '::1',
  'host.docker.internal'];

// The reported difference is the first component that disagrees, and a scheme
// mismatch explains a port mismatch rather than the other way round.
export const COMPONENTS = ['scheme', 'host', 'port', 'path'];

/**
 * Which components disagree? Pure, and one subsumption is applied. Changing
 * the scheme changes the default port with it, so http against https would
 * otherwise be two differences and never a near miss - which is precisely the
 * case this check exists to name.
 */
function difference(got, want) {
  const diff = COMPONENTS.filter((k) => got[k] !== want[k]);
  if (diff.includes('scheme') && diff.includes('port')
    && got.port === DEFAULT_PORTS[got.scheme]
    && want.port === DEFAULT_PORTS[want.scheme]) {
    return diff.filter((k) => k !== 'port');
  }
  return diff;
}

/**
 * Split a callback URL into the four parts Slack compares. Pure.
 * Returns [parts, notes].
 */
export function normaliseUrl(url) {
  const empty = { scheme: '', host: '', port: '', path: '' };
  let text = String(url ?? '').trim();
  const notes = [];
  if (!text) return [empty, ['empty']];
  if (!text.includes('://')) {
    notes.push('no-scheme');
    text = `https://${text}`;
  }
  let parsed;
  try {
    parsed = new URL(text);
  } catch {
    return [empty, notes.concat(['unparseable'])];
  }
  const scheme = parsed.protocol.replace(':', '').toLowerCase();
  const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, '');
  if (!host) return [empty, notes.concat(['unparseable'])];
  if (parsed.username || parsed.password) notes.push('userinfo');
  if (parsed.port && parsed.port === DEFAULT_PORTS[scheme]) {
    notes.push('explicit-default-port');
  }
  if (parsed.search) notes.push('has-query');
  if (parsed.hash) notes.push('has-fragment');
  const parts = {
    scheme,
    host,
    port: parsed.port || DEFAULT_PORTS[scheme] || '',
    path: parsed.pathname || '/',
  };
  return [parts, notes];
}

/** Everything worth saying about one configured Redirect URL. Pure. */
export function redirectConcerns(url) {
  const [parts, notes] = normaliseUrl(url);
  if (notes.includes('empty')) {
    return [['empty', 'an empty entry matches nothing and hides the list length']];
  }
  if (notes.includes('unparseable')) {
    return [['unusable', 'this is not a URL Slack can match a callback against']];
  }
  const out = [];
  if (notes.includes('no-scheme')) {
    out.push(['no-scheme', 'the entry has no scheme, so it cannot match anything; '
      + 'write it out in full']);
  }
  if (LOCAL_HOSTS.includes(parts.host)) {
    out.push(['loopback', 'an install that lands here only works on the machine that '
      + 'ran it; remove it before the app is distributed']);
  } else if (parts.scheme === 'http') {
    out.push(['insecure-scheme', 'the authorization code travels back on this URL in '
      + 'clear text, and a callback built as https will not match it']);
  }
  if (notes.includes('has-query')) {
    out.push(['has-query', 'Slack does not match on the query string; carry '
      + 'per-install data in the state parameter']);
  }
  if (notes.includes('has-fragment')) {
    out.push(['has-fragment', 'a fragment never reaches your server, so it cannot be '
      + 'part of a callback URL']);
  }
  if (notes.includes('userinfo')) {
    out.push(['userinfo', 'credentials embedded in the URL will not match and should '
      + 'not exist']);
  }
  if (notes.includes('explicit-default-port')) {
    out.push(['explicit-default-port', 'the default port is spelled out here and '
      + 'omitted by every browser']);
  }
  if (parts.path !== '/' && parts.path.endsWith('/')) {
    out.push(['trailing-slash', 'matching runs from this entry forward, so it does '
      + 'not cover the same path without the trailing slash']);
  }
  return out;
}

/**
 * Is this deployed callback covered by any configured entry? Pure.
 * Returns [state, detail]; exact, prefix, near-miss, no-match, unusable.
 */
export function matchCallback(callback, configured) {
  const [want, notes] = normaliseUrl(callback);
  if (notes.includes('empty') || notes.includes('unparseable') || !want.host) {
    return ['unusable', 'the callback URL given is not a URL'];
  }
  const entries = [];
  for (const raw of configured ?? []) {
    const [got, gnotes] = normaliseUrl(raw);
    if (gnotes.includes('empty') || gnotes.includes('unparseable') || !got.host) {
      continue;
    }
    entries.push([raw, got]);
  }
  if (!entries.length) {
    return ['no-match', 'no usable entry is configured, so every install fails'];
  }
  for (const [raw, got] of entries) {
    if (COMPONENTS.every((k) => got[k] === want[k])) {
      return ['exact', `matches ${raw} character for character`];
    }
  }
  for (const [raw, got] of entries) {
    const sameOrigin = ['scheme', 'host', 'port'].every((k) => got[k] === want[k]);
    if (sameOrigin && want.path.startsWith(got.path)) {
      return ['prefix', `${raw} is a prefix of this callback, which Slack accepts; `
        + 'the string sent as redirect_uri still has to be one Slack can match'];
    }
  }
  let best = null;
  let bestDiff = null;
  for (const [raw, got] of entries) {
    const diff = difference(got, want);
    if (bestDiff === null || diff.length < bestDiff.length) {
      best = raw;
      bestDiff = diff;
    }
  }
  if (bestDiff.length === 1) {
    return ['near-miss', `matches ${best} in every part except the ${bestDiff[0]}`];
  }
  return ['no-match',
    `the closest entry is ${best} and it differs in the ${bestDiff.join(', ')}`];
}

/**
 * Apply the present-in-both-legs-or-neither rule. Pure.
 * Returns [state, detail]; identical, both-absent, ambiguous, authorize-only,
 * exchange-only, different.
 */
export function parameterSymmetry(authorizeRedirect, exchangeRedirect, configuredCount) {
  const left = String(authorizeRedirect ?? '').trim();
  const right = String(exchangeRedirect ?? '').trim();
  if (left && right) {
    const a = normaliseUrl(left)[0];
    const b = normaliseUrl(right)[0];
    if (COMPONENTS.every((k) => a[k] === b[k])) {
      return ['identical', 'both legs send the same redirect_uri'];
    }
    return ['different', `the authorize URL sends ${left} and the exchange sends `
      + `${right}; they must be the identical string`];
  }
  if (left && !right) {
    return ['authorize-only', 'the authorize URL carries redirect_uri and the '
      + 'exchange omits it; send it in both or neither'];
  }
  if (right && !left) {
    return ['exchange-only', 'the exchange sends redirect_uri and the authorize URL '
      + 'omits it; send it in both or neither'];
  }
  if (Number(configuredCount ?? 0) > 1) {
    return ['ambiguous', `neither leg sends redirect_uri and ${Number(configuredCount)} `
      + 'URLs are configured, so Slack has nothing to disambiguate with'];
  }
  return ['both-absent', 'neither leg sends redirect_uri and exactly one URL is '
    + 'configured, which Slack tolerates; sending it in both is still the safer shape'];
}

function arg(args, name, fallback = '') {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

async function exportManifest(token, appId) {
  const url = `${API}apps.manifest.export?app_id=${encodeURIComponent(appId)}`;
  const r = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
  try {
    return await r.json();
  } catch {
    return { ok: false, error: 'unparseable_body' };
  }
}

async function main() {
  const args = process.argv.slice(2);
  const appId = arg(args, '--app-id');
  const tokenEnv = arg(args, '--config-token-env', 'SLACK_CONFIG_ACCESS_TOKEN');
  let configured = arg(args, '--configured').split(',')
    .map((u) => u.trim()).filter(Boolean);
  let findings = 0;

  const token = process.env[tokenEnv];
  if (appId && token && !configured.length) {
    const body = await exportManifest(token, appId);
    if (body.ok !== true) {
      console.warn('manifest   unavailable    apps.manifest.export answered ok: false, '
        + `error=${body.error}`);
    } else {
      const oauthConfig = (body.manifest ?? body).oauth_config ?? {};
      configured = [...(oauthConfig.redirect_urls ?? [])];
      console.log(`manifest   ok             ${configured.length} configured redirect `
        + `url(s) for ${appId}`);
    }
  } else if (!configured.length) {
    console.log(`manifest   skipped        set ${tokenEnv}, or pass --configured with `
      + 'the entries from the OAuth & Permissions page');
  }

  configured.forEach((entry, i) => {
    console.log(`configured ${String(i + 1).padEnd(14)} ${entry}`);
    for (const [code, why] of redirectConcerns(entry)) {
      console.warn(`           ${code.padEnd(14)} ${why}`);
      findings += 1;
    }
  });

  for (const callback of arg(args, '--callbacks').split(',')
    .map((u) => u.trim()).filter(Boolean)) {
    const [state, detail] = matchCallback(callback, configured);
    const line = `callback   ${state.padEnd(14)} ${callback} -> ${detail}`;
    if (state === 'exact' || state === 'prefix') console.log(line);
    else { console.warn(line); findings += 1; }
  }

  const [state, detail] = parameterSymmetry(arg(args, '--authorize-redirect'),
    arg(args, '--exchange-redirect'), configured.length);
  const line = `symmetry   ${state.padEnd(14)} ${detail}`;
  if (state === 'identical' || state === 'both-absent') console.log(line);
  else { console.warn(line); findings += 1; }

  if (!findings) {
    console.log('verdict    clean          every deployed callback is covered and both '
      + 'legs agree');
    return;
  }
  console.warn(`verdict    ${findings} finding(s)`);
  console.warn('  repair: add the exact callback URL for every environment under OAuth '
    + '& Permissions, scheme and path included');
  console.warn('  repair: send the identical redirect_uri in both the authorize URL and '
    + 'the oauth.v2.access exchange, or omit it from both');
  console.warn('  repair: keep the list in the manifest under '
    + 'oauth_config.redirect_urls so environments cannot drift');
  console.warn('  note:   no code was redeemed to establish this; oauth.v2.access is a '
    + 'write and would install the app');
  process.exitCode = 1;
}

// Only run when invoked directly, so importing this module in the tests does
// not execute main().
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

No credential appears in these fixtures at all, which is the point worth noticing: the whole diagnosis runs on URLs that are already in a browser address bar during every install. The assertions that matter are the asymmetric ones — a configured path may be shorter than the callback and never longer, so /slack covering /slack/callback and /slack/callback/ failing to cover /slack/callback are both pinned — and the ambiguous case, where neither leg sends the parameter and a second configured URL has quietly turned a working install into a broken one.

test_slack_oauth_redirect.py
from slack_oauth_redirect import (
    match_callback, normalise_url, parameter_symmetry, redirect_concerns,
)

PROD = "https://app.example.com/slack/callback"
STAGE = "https://staging.example.com/slack/callback"


def test_the_default_port_is_made_explicit_so_two_spellings_agree():
    assert normalise_url("https://a.example.com/x")[0]["port"] == "443"
    assert normalise_url("https://a.example.com:443/x")[0]["port"] == "443"


def test_the_host_folds_case_and_the_path_does_not():
    parts, _notes = normalise_url("https://APP.Example.COM/Slack/Callback")
    assert parts["host"] == "app.example.com"
    assert parts["path"] == "/Slack/Callback"


def test_an_entry_with_no_scheme_is_flagged_rather_than_guessed_at():
    assert "no-scheme" in normalise_url("app.example.com/slack")[1]
    assert redirect_concerns("app.example.com/slack")[0][0] == "no-scheme"


def test_a_localhost_entry_is_reported_before_it_reaches_a_customer():
    codes = [c for c, _w in redirect_concerns("http://localhost:3000/slack/callback")]
    assert "loopback" in codes
    assert "insecure-scheme" not in codes


def test_plain_http_on_a_real_host_is_its_own_finding():
    codes = [c for c, _w in redirect_concerns("http://app.example.com/slack/callback")]
    assert "insecure-scheme" in codes


def test_a_query_string_is_a_misunderstanding_and_says_so():
    codes = [c for c, w in redirect_concerns(PROD + "?tenant=northwind")]
    assert "has-query" in codes


def test_a_trailing_slash_on_a_configured_entry_is_reported():
    codes = [c for c, _w in redirect_concerns(PROD + "/")]
    assert "trailing-slash" in codes


def test_an_empty_or_unusable_entry_is_named_rather_than_skipped():
    assert redirect_concerns("")[0][0] == "empty"
    assert redirect_concerns("https://")[0][0] == "unusable"


def test_an_exact_match_is_exact():
    state, detail = match_callback(PROD, [STAGE, PROD])
    assert state == "exact"
    assert PROD in detail


def test_a_configured_prefix_covers_a_longer_callback_path():
    assert match_callback(PROD, ["https://app.example.com/slack"])[0] == "prefix"


def test_a_configured_path_that_is_longer_does_not_cover_the_callback():
    assert match_callback(PROD, [PROD + "/"])[0] != "prefix"
    assert match_callback(PROD, [PROD + "/"])[0] != "exact"


def test_the_scheme_alone_is_named_as_the_difference():
    state, detail = match_callback("http://app.example.com/slack/callback", [PROD])
    assert state == "near-miss"
    assert "scheme" in detail


def test_a_port_that_only_exists_inside_the_container_is_named_too():
    state, detail = match_callback("https://app.example.com:8080/slack/callback",
                                   [PROD])
    assert state == "near-miss"
    assert "port" in detail


def test_a_wholly_different_host_and_path_is_a_plain_no_match():
    state, detail = match_callback("https://other.example.net/auth", [PROD])
    assert state == "no-match"
    assert "host" in detail


def test_an_empty_configured_list_fails_every_install():
    assert match_callback(PROD, [])[0] == "no-match"


def test_both_legs_sending_the_same_string_is_the_correct_shape():
    assert parameter_symmetry(PROD, PROD, 2)[0] == "identical"


def test_sending_it_in_one_leg_only_is_refused_either_way_round():
    assert parameter_symmetry(PROD, "", 1)[0] == "authorize-only"
    assert parameter_symmetry("", PROD, 1)[0] == "exchange-only"


def test_two_different_strings_are_reported_as_different_not_as_a_mismatch():
    state, detail = parameter_symmetry(PROD, STAGE, 2)
    assert state == "different"
    assert STAGE in detail


def test_omitting_it_everywhere_is_tolerated_with_exactly_one_url():
    assert parameter_symmetry("", "", 1)[0] == "both-absent"


def test_omitting_it_everywhere_becomes_ambiguous_the_day_a_second_url_appears():
    state, detail = parameter_symmetry("", "", 2)
    assert state == "ambiguous"
    assert "2" in detail
slack-oauth-redirect.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
  matchCallback, normaliseUrl, parameterSymmetry, redirectConcerns,
} from './slack-oauth-redirect.mjs';

const PROD = 'https://app.example.com/slack/callback';
const STAGE = 'https://staging.example.com/slack/callback';

const codes = (url) => redirectConcerns(url).map(([c]) => c);

test('the default port is made explicit so two spellings agree', () => {
  assert.equal(normaliseUrl('https://a.example.com/x')[0].port, '443');
  assert.equal(normaliseUrl('https://a.example.com:443/x')[0].port, '443');
});

test('the host folds case and the path does not', () => {
  const [parts] = normaliseUrl('https://APP.Example.COM/Slack/Callback');
  assert.equal(parts.host, 'app.example.com');
  assert.equal(parts.path, '/Slack/Callback');
});

test('an entry with no scheme is flagged rather than guessed at', () => {
  assert.equal(normaliseUrl('app.example.com/slack')[1].includes('no-scheme'), true);
  assert.equal(redirectConcerns('app.example.com/slack')[0][0], 'no-scheme');
});

test('a localhost entry is reported before it reaches a customer', () => {
  const found = codes('http://localhost:3000/slack/callback');
  assert.equal(found.includes('loopback'), true);
  assert.equal(found.includes('insecure-scheme'), false);
});

test('plain http on a real host is its own finding', () => {
  assert.equal(codes('http://app.example.com/slack/callback')
    .includes('insecure-scheme'), true);
});

test('a query string is a misunderstanding and says so', () => {
  assert.equal(codes(`${PROD}?tenant=northwind`).includes('has-query'), true);
});

test('a trailing slash on a configured entry is reported', () => {
  assert.equal(codes(`${PROD}/`).includes('trailing-slash'), true);
});

test('an empty or unusable entry is named rather than skipped', () => {
  assert.equal(redirectConcerns('')[0][0], 'empty');
  assert.equal(redirectConcerns('https://')[0][0], 'unusable');
});

test('an exact match is exact', () => {
  const [state, detail] = matchCallback(PROD, [STAGE, PROD]);
  assert.equal(state, 'exact');
  assert.match(detail, /app\.example\.com/);
});

test('a configured prefix covers a longer callback path', () => {
  assert.equal(matchCallback(PROD, ['https://app.example.com/slack'])[0], 'prefix');
});

test('a configured path that is longer does not cover the callback', () => {
  assert.notEqual(matchCallback(PROD, [`${PROD}/`])[0], 'prefix');
  assert.notEqual(matchCallback(PROD, [`${PROD}/`])[0], 'exact');
});

test('the scheme alone is named as the difference', () => {
  const [state, detail] = matchCallback('http://app.example.com/slack/callback',
    [PROD]);
  assert.equal(state, 'near-miss');
  assert.match(detail, /scheme/);
});

test('a port that only exists inside the container is named too', () => {
  const [state, detail] = matchCallback('https://app.example.com:8080/slack/callback',
    [PROD]);
  assert.equal(state, 'near-miss');
  assert.match(detail, /port/);
});

test('a wholly different host and path is a plain no match', () => {
  const [state, detail] = matchCallback('https://other.example.net/auth', [PROD]);
  assert.equal(state, 'no-match');
  assert.match(detail, /host/);
});

test('an empty configured list fails every install', () => {
  assert.equal(matchCallback(PROD, [])[0], 'no-match');
});

test('both legs sending the same string is the correct shape', () => {
  assert.equal(parameterSymmetry(PROD, PROD, 2)[0], 'identical');
});

test('sending it in one leg only is refused either way round', () => {
  assert.equal(parameterSymmetry(PROD, '', 1)[0], 'authorize-only');
  assert.equal(parameterSymmetry('', PROD, 1)[0], 'exchange-only');
});

test('two different strings are reported as different not as a mismatch', () => {
  const [state, detail] = parameterSymmetry(PROD, STAGE, 2);
  assert.equal(state, 'different');
  assert.match(detail, /staging/);
});

test('omitting it everywhere is tolerated with exactly one url', () => {
  assert.equal(parameterSymmetry('', '', 1)[0], 'both-absent');
});

test('omitting it everywhere becomes ambiguous the day a second url appears', () => {
  const [state, detail] = parameterSymmetry('', '', 2);
  assert.equal(state, 'ambiguous');
  assert.match(detail, /2/);
});

FAQ

Why not just run one install and read the error from oauth.v2.access?

Because that call is a write, and an unusually consequential one. It redeems an authorization code, which is single use and expires within minutes, so a test run either burns the code a real user was in the middle of using or completes an installation into a workspace with every scope your authorize URL asked for. There is no read-only variant. Everything this note needs is already in your hands: the configured list, the URLs your deployments use, and the two strings your code sends. The script compares those and never transmits a code.

Does Slack match the redirect URL exactly, or by prefix?

By prefix, forward from the configured entry, after the scheme, host and port have matched exactly. A configured https://app.example.com/slack covers /slack/callback and /slack/callback/v2 underneath it. It does not cover a different port, it does not cover the http form, and it does not work backwards: a configured path ending in a slash cannot cover the same path without one. The script encodes that asymmetry deliberately, because normalising the slash away on both sides would report a pass on an install that fails.

It worked for a year and broke the day we added a second environment. Why?

Almost certainly because neither leg of your flow sends redirect_uri. While exactly one Redirect URL is configured Slack can fill in the blank, and while that was true your install worked. Adding a second URL makes the blank ambiguous and every install starts failing, with no deploy and no code change to point at. That is the ambiguous verdict from parameter_symmetry, and the repair is to send the parameter explicitly in both the authorize URL and the exchange.

We terminate TLS at the load balancer and build the callback from the request. Is that the problem?

It is the single most common cause of a near-miss on the scheme. Inside the perimeter your application sees plain HTTP, so a callback URL built from the inbound request comes out as http://app.example.com/slack/callback and gets held against an allow list containing only the https form. Honouring X-Forwarded-Proto fixes it; putting the callback URL in configuration as a literal string fixes it better, because then the deployed value and the configured value are the same string in two places rather than one string and one derivation.

The install succeeds and the token stops working later. Same problem?

No, and the boundary is clean. This note covers everything up to the moment oauth.v2.access answers ok: true. A token that expires after twelve hours means rotation is switched on and the refresh is not being done; a refresh that fails once and poisons the pair is the single-use refresh token being replayed. Both are linked below, and neither of them can be caused by a redirect URL, because by the time they happen the redirect URL has already done its job.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.