Skip to content

Diagnostic Slack

every Slack profile has a null email and nothing errored

The nightly user sync has been green for four months. It reads every member out of Slack, writes them to the warehouse, and joins them against the HR system on email. The join has matched nothing since the day it shipped, because every row it wrote has email = null — and users.list returned ok: true, with complete-looking profiles, every single night.

Read-only token Python and Node.js Tests included
Two people working in a cluttered store.
Photo by Phuong Nguyen on Unsplash
The short answer

Page users.list, count the members who are neither deleted nor bots, and count how many of those have a truthy profile.email. Zero out of several hundred is not a data problem: it is users:read.email missing from the token.

Confirm it on the same response by reading the X-OAuth-Scopes header, which lists what this token actually holds. The repair is to add users:read.email to Bot Token Scopes, reinstall the app, and replace the deployed token — adding a scope in the app config changes nothing about the token already in production.

The problem in plain words

Every other scope failure in Slack announces itself. Ask for something the token cannot do and you get ok: false with error: missing_scope, plus needed and provided naming the exact fix. This one does not. users:read grants profiles; the email address is withheld from those profiles by a second scope, and the way it is withheld is by leaving the key out.

A missing key is not an error in any language your sync is written in. profile.get("email") is None. profile.email is undefined. Both flow straight into the row, the insert succeeds, the job exits zero, and the dashboard is green. The only visible symptom is downstream and much later: a join that returns no rows, an onboarding email that never sends, a mapping table that stays empty.

It survives review, too. The profile object that comes back is genuinely rich — display name, real name, title, avatars in six sizes, timezone — so a developer inspecting one member sees a full record and concludes the API is working. It is working. It is answering the question the token was allowed to ask.

users.listcalledusers:read grantedProfiles comeback fullok is trueEmail keyabsentsecond scopewithheld itRows written asnullinsert succeedsJoin matchesnothingjob still green
There is no error at any point in this chain. The field was withheld by leaving the key out, and a missing key is not an exception in any language the job is written in.

Why it happens

Email is deliberately a separate grant. users:read is the profile scope; users:read.email is the address. Slack split them because an app that needs to render a member list does not need everyone's email, and admins approving an install can see the difference.

Nothing errors, because nothing was refused. The response is a valid, complete answer to a request from a token without that grant. There is no needed field to read and no exception to catch, which is what makes this the quietest scope failure in the API.

The lookup direction fails just as quietly. users.lookupByEmail without the scope returns users_not_found for an address you know exists — an answer that reads like "no such person" rather than "you may not ask". Code that treats that as a soft miss will create duplicate accounts rather than raise.

The scope alone does not guarantee a value. Some workspaces and many Grid orgs hide email by admin policy, and guest accounts may have none to show. So assert per member, not once per run: the correct post-fix state is "almost every human has an email", not "the scope is present".

Adding the scope requires a reinstall. The token in production was minted with the grant it had at install time. Editing Bot Token Scopes changes what the next install requests. Until someone reinstalls and swaps the deployed token, the census will read exactly the same.

The fix, as a flow

The census counts humans and the header names the grant, and the two are read from the same response. A scope list from the app config page describes the app you meant to deploy, not the token that is running.

Humans counted, header readfrom one responseNone, scope absentadd it and reinstallNone, scope grantedadmin policy, not scopesMost but not allguests and hidden addressesEvery human has onethe grant is working
A handful of humans without an address is ordinary workspace life. Reporting it as the scope gap sends a team through a reinstall that changes nothing at all.

How to fix it

Page users.list rather than reading the first hundred

users.list?limit=200 and follow response_metadata.next_cursor until it is empty. A single page in a large workspace can be all bots and deactivated accounts, which produces a census of zero humans and no conclusion at all.

Count humans, not members

Exclude deleted, is_bot, and USLACKBOT. Bots have no email by definition and deactivated accounts often lose theirs, so leaving them in the denominator turns a clean finding into a plausible-looking ratio.

Read X-OAuth-Scopes off the same response

The header lists the scopes this token actually holds. Reading it from the response you are judging — rather than from the app configuration page, or from a list someone pasted into a ticket — is the difference between diagnosing the deployed token and diagnosing the intended one.

Separate none from some

Zero emails with the scope absent is the finding. A handful missing out of hundreds is not: those are guests, unconfirmed accounts or admin-hidden addresses, and adding a scope will not change them. Reporting the second as the first sends a team through a reinstall that fixes nothing.

Confirm from the other direction

users.lookupByEmail?email= with an address you know is in the workspace. missing_scope, or a persistent users_not_found for a member you can see in the census, corroborates the finding without needing any write anywhere.

Add the scope, reinstall, replace the token

All three, in that order. Marketplace submissions need a written justification for this scope, so budget for that if the app is distributed. Then re-run the census and assert per member rather than trusting the grant.

How to check it worked

After the reinstall and the token swap, re-run. The census should flip from none to nearly all, and the header should list the scope.

python3 slack_email_scope_audit.py
# complete   412 of 418 humans have an email; users:read.email is granted

The full code

One paginated read of users.list, plus the X-OAuth-Scopes header off the same response. Both classifiers are pure: parse_scopes turns the header into a set, and verdict does the census and decides whether the emails are missing because of the grant or in spite of it.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 20 Slack fixes, free and open source.
slack_email_scope_audit.py
"""Decide whether Slack profiles have no email, or the token may not see it.

Read only. GET requests and nothing else: users:read is enough to run this, and
whether users:read.email is present is the thing being measured. The repair is a
scope change and a reinstall, and is printed rather than performed.
"""
import argparse
import logging
import os
import sys

import requests

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

API = "https://slack.com/api/"
EMAIL_SCOPE = "users:read.email"


def parse_scopes(header):
    """Turn an X-OAuth-Scopes header into a set. Pure.

    Slack sends a comma separated list, occasionally with spaces after the
    commas and occasionally absent altogether on a cached or proxied response.
    """
    if not header:
        return set()
    return {s.strip() for s in header.split(",") if s.strip()}


def verdict(members, scopes):
    """Census the members and decide what the missing emails mean. Pure.

    `members` is the users.list array, `scopes` the set from X-OAuth-Scopes.
    Bots and deactivated accounts are excluded from the denominator: they have
    no email to show, and counting them turns a clean finding into a ratio.
    """
    humans = [m for m in members
              if not m.get("deleted") and not m.get("is_bot")
              and m.get("id") != "USLACKBOT"]
    total = len(humans)
    if not total:
        return ("no-humans",
                "no active human members in the page(s) read, so there is nothing "
                "to census. Page further before concluding anything.")

    with_email = sum(1 for m in humans if (m.get("profile") or {}).get("email"))
    granted = EMAIL_SCOPE in scopes

    if with_email == 0 and not granted:
        return ("scope-missing",
                "0 of %d humans have an email and %s is not on this token. The "
                "field is withheld, not absent: nothing errored because nothing "
                "was refused." % (total, EMAIL_SCOPE))
    if with_email == 0:
        return ("scope-granted-none-visible",
                "0 of %d humans have an email even though %s is granted. That is "
                "admin policy or Grid restriction, not the scope, and no reinstall "
                "will change it." % (total, EMAIL_SCOPE))
    if with_email < total:
        return ("partial",
                "%d of %d humans have an email%s. Guests, unconfirmed accounts and "
                "admin-hidden addresses look exactly like this, so assert per "
                "member rather than per run."
                % (with_email, total,
                   "" if granted else "; note %s is absent, so something other "
                   "than this token supplied them" % EMAIL_SCOPE))
    return ("complete",
            "%d of %d humans have an email; %s is granted"
            % (with_email, total, EMAIL_SCOPE))


def page_users(session, limit, max_pages):
    """Walk users.list, keeping the scope header from the last response."""
    members, cursor, scopes, pages = [], "", set(), 0
    while pages < max_pages:
        params = {"limit": str(limit)}
        if cursor:
            params["cursor"] = cursor
        r = session.get(API + "users.list", params=params, timeout=60)
        scopes = parse_scopes(r.headers.get("X-OAuth-Scopes"))
        body = r.json()
        if body.get("ok") is not True:
            return members, scopes, body
        members.extend(body.get("members") or [])
        cursor = ((body.get("response_metadata") or {}).get("next_cursor") or "").strip()
        pages += 1
        if not cursor:
            break
    return members, scopes, {"ok": True}


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--limit", type=int, default=200, help="page size (default 200)")
    ap.add_argument("--max-pages", type=int, default=20,
                    help="stop after this many pages (default 20)")
    args = ap.parse_args()

    token = os.environ.get("SLACK_BOT_TOKEN")
    if not token:
        log.error("set SLACK_BOT_TOKEN (users:read is enough to run the census)")
        return 2

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + token})

    members, scopes, last = page_users(s, args.limit, args.max_pages)
    if last.get("ok") is not True:
        log.error("users.list answered 200 with ok: false, error=%s", last.get("error"))
        return 2

    state, detail = verdict(members, scopes)
    if state in ("complete", "no-humans"):
        log.info("%-26s %s", state, detail)
    else:
        log.warning("%-26s %s", state, detail)

    if state == "scope-missing":
        log.warning("  granted: %s", ", ".join(sorted(scopes)) or "<no header on the response>")
        log.warning("  repair: add %s to Bot Token Scopes, reinstall the app, and "
                    "replace the deployed token", EMAIL_SCOPE)
        log.warning("  the token in production keeps the grant it was minted with; "
                    "editing the app config alone changes nothing")
    elif state == "scope-granted-none-visible":
        log.warning("  repair: ask a workspace admin whether email visibility is "
                    "restricted; the scope is already there")
    elif state == "partial":
        log.warning("  repair: none at the scope level. Handle a missing email "
                    "per member rather than failing the run")

    log.info("%d member(s) read, verdict %s", len(members), state)
    return 1 if state in ("scope-missing", "scope-granted-none-visible") else 0


if __name__ == "__main__":
    sys.exit(main())
slack-email-scope-audit.mjs
/**
 * Decide whether Slack profiles have no email, or the token may not see it.
 *
 * Read only. GET requests and nothing else: users:read is enough to run this,
 * and whether users:read.email is present is the thing being measured. The
 * repair is a scope change and a reinstall, and is printed rather than done.
 */
const API = 'https://slack.com/api/';
export const EMAIL_SCOPE = 'users:read.email';

/**
 * Turn an X-OAuth-Scopes header into a Set. Pure. Slack sends a comma separated
 * list, sometimes with spaces, and sometimes not at all on a proxied response.
 */
export function parseScopes(header) {
  if (!header) return new Set();
  return new Set(header.split(',').map((s) => s.trim()).filter(Boolean));
}

/**
 * Census the members and decide what the missing emails mean. Pure.
 * Bots and deactivated accounts are excluded from the denominator: they have no
 * email to show, and counting them turns a clean finding into a ratio.
 */
export function verdict(members, scopes) {
  const humans = members.filter(
    (m) => !m.deleted && !m.is_bot && m.id !== 'USLACKBOT');
  const total = humans.length;
  if (!total) {
    return ['no-humans',
      'no active human members in the page(s) read, so there is nothing to ' +
      'census. Page further before concluding anything.'];
  }

  const withEmail = humans.filter((m) => m.profile?.email).length;
  const granted = scopes.has(EMAIL_SCOPE);

  if (withEmail === 0 && !granted) {
    return ['scope-missing',
      `0 of ${total} humans have an email and ${EMAIL_SCOPE} is not on this ` +
      'token. The field is withheld, not absent: nothing errored because ' +
      'nothing was refused.'];
  }
  if (withEmail === 0) {
    return ['scope-granted-none-visible',
      `0 of ${total} humans have an email even though ${EMAIL_SCOPE} is granted. ` +
      'That is admin policy or Grid restriction, not the scope, and no reinstall ' +
      'will change it.'];
  }
  if (withEmail < total) {
    const note = granted ? ''
      : `; note ${EMAIL_SCOPE} is absent, so something other than this token supplied them`;
    return ['partial',
      `${withEmail} of ${total} humans have an email${note}. Guests, unconfirmed ` +
      'accounts and admin-hidden addresses look exactly like this, so assert per ' +
      'member rather than per run.'];
  }
  return ['complete',
    `${withEmail} of ${total} humans have an email; ${EMAIL_SCOPE} is granted`];
}

async function pageUsers(token, limit, maxPages) {
  const members = [];
  let cursor = '';
  let scopes = new Set();
  let pages = 0;
  while (pages < maxPages) {
    const url = new URL(API + 'users.list');
    url.searchParams.set('limit', String(limit));
    if (cursor) url.searchParams.set('cursor', cursor);
    const res = await fetch(url, { headers: { Authorization: `Bearer ${token}` } });
    scopes = parseScopes(res.headers.get('x-oauth-scopes'));
    const body = await res.json();
    if (body.ok !== true) return { members, scopes, last: body };
    members.push(...(body.members ?? []));
    cursor = (body.response_metadata?.next_cursor ?? '').trim();
    pages += 1;
    if (!cursor) break;
  }
  return { members, scopes, last: { ok: true } };
}

async function main() {
  const token = process.env.SLACK_BOT_TOKEN;
  if (!token) {
    console.error('set SLACK_BOT_TOKEN (users:read is enough to run the census)');
    process.exitCode = 2;
    return;
  }

  const args = process.argv.slice(2);
  const li = args.indexOf('--limit');
  const pi = args.indexOf('--max-pages');
  const limit = li === -1 ? 200 : Number(args[li + 1]);
  const maxPages = pi === -1 ? 20 : Number(args[pi + 1]);

  const { members, scopes, last } = await pageUsers(token, limit, maxPages);
  if (last.ok !== true) {
    console.error(`users.list answered 200 with ok: false, error=${last.error}`);
    process.exitCode = 2;
    return;
  }

  const [state, detail] = verdict(members, scopes);
  if (state === 'complete' || state === 'no-humans') {
    console.log(`${state.padEnd(26)} ${detail}`);
  } else {
    console.warn(`${state.padEnd(26)} ${detail}`);
  }

  if (state === 'scope-missing') {
    console.warn(`  granted: ${[...scopes].sort().join(', ') || '<no header on the response>'}`);
    console.warn(`  repair: add ${EMAIL_SCOPE} to Bot Token Scopes, reinstall the app, ` +
                 'and replace the deployed token');
    console.warn('  the token in production keeps the grant it was minted with; ' +
                 'editing the app config alone changes nothing');
  } else if (state === 'scope-granted-none-visible') {
    console.warn('  repair: ask a workspace admin whether email visibility is ' +
                 'restricted; the scope is already there');
  } else if (state === 'partial') {
    console.warn('  repair: none at the scope level. Handle a missing email per ' +
                 'member rather than failing the run');
  }

  console.log(`${members.length} member(s) read, verdict ${state}`);
  process.exitCode = ['scope-missing', 'scope-granted-none-visible'].includes(state) ? 1 : 0;
}

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

Add a test

Two cases decide whether this audit is useful or merely noisy. All emails missing with the scope absent is the finding. A few missing out of hundreds, scope present, is ordinary workspace life — guests and hidden addresses — and must come back as its own state, because the repair for it is not a reinstall.

test_slack_email_scope_audit.py
from slack_email_scope_audit import parse_scopes, verdict


def human(uid, email=None):
    profile = {"real_name": "A Person"}
    if email:
        profile["email"] = email
    return {"id": uid, "deleted": False, "is_bot": False, "profile": profile}


def test_no_emails_and_no_scope_is_the_finding():
    members = [human("U1"), human("U2")]
    state, detail = verdict(members, {"users:read"})
    assert state == "scope-missing"
    assert "0 of 2" in detail


def test_no_emails_with_the_scope_is_not_a_scope_problem():
    members = [human("U1"), human("U2")]
    state, detail = verdict(members, {"users:read", "users:read.email"})
    assert state == "scope-granted-none-visible"
    assert "admin policy" in detail


def test_a_few_missing_is_ordinary_and_says_so():
    members = [human("U1", "a@example.com"), human("U2")]
    state, detail = verdict(members, {"users:read", "users:read.email"})
    assert state == "partial"
    assert "per member" in detail


def test_every_human_with_an_email_is_complete():
    members = [human("U1", "a@example.com"), human("U2", "b@example.com")]
    assert verdict(members, {"users:read.email"})[0] == "complete"


def test_bots_and_deactivated_accounts_are_not_in_the_denominator():
    members = [
        human("U1", "a@example.com"),
        {"id": "U2", "deleted": True, "is_bot": False, "profile": {}},
        {"id": "B1", "deleted": False, "is_bot": True, "profile": {}},
        {"id": "USLACKBOT", "deleted": False, "is_bot": False, "profile": {}},
    ]
    state, detail = verdict(members, {"users:read.email"})
    assert state == "complete"
    assert "1 of 1" in detail


def test_a_page_of_only_bots_yields_no_verdict():
    members = [{"id": "B1", "deleted": False, "is_bot": True, "profile": {}}]
    assert verdict(members, set())[0] == "no-humans"


def test_scope_header_parsing_survives_spaces_and_absence():
    assert parse_scopes("users:read, users:read.email ,team:read") == {
        "users:read", "users:read.email", "team:read"}
    assert parse_scopes(None) == set()
    assert parse_scopes("") == set()
slack-email-scope-audit.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseScopes, verdict } from './slack-email-scope-audit.mjs';

function human(id, email) {
  const profile = { real_name: 'A Person' };
  if (email) profile.email = email;
  return { id, deleted: false, is_bot: false, profile };
}

test('no emails and no scope is the finding', () => {
  const [state, detail] = verdict([human('U1'), human('U2')], new Set(['users:read']));
  assert.equal(state, 'scope-missing');
  assert.match(detail, /0 of 2/);
});

test('no emails with the scope is not a scope problem', () => {
  const [state, detail] = verdict(
    [human('U1'), human('U2')], new Set(['users:read', 'users:read.email']));
  assert.equal(state, 'scope-granted-none-visible');
  assert.match(detail, /admin policy/);
});

test('a few missing is ordinary and says so', () => {
  const [state, detail] = verdict(
    [human('U1', 'a@example.com'), human('U2')],
    new Set(['users:read', 'users:read.email']));
  assert.equal(state, 'partial');
  assert.match(detail, /per member/);
});

test('every human with an email is complete', () => {
  const members = [human('U1', 'a@example.com'), human('U2', 'b@example.com')];
  assert.equal(verdict(members, new Set(['users:read.email']))[0], 'complete');
});

test('bots and deactivated accounts are not in the denominator', () => {
  const members = [
    human('U1', 'a@example.com'),
    { id: 'U2', deleted: true, is_bot: false, profile: {} },
    { id: 'B1', deleted: false, is_bot: true, profile: {} },
    { id: 'USLACKBOT', deleted: false, is_bot: false, profile: {} },
  ];
  const [state, detail] = verdict(members, new Set(['users:read.email']));
  assert.equal(state, 'complete');
  assert.match(detail, /1 of 1/);
});

test('a page of only bots yields no verdict', () => {
  const members = [{ id: 'B1', deleted: false, is_bot: true, profile: {} }];
  assert.equal(verdict(members, new Set())[0], 'no-humans');
});

test('scope header parsing survives spaces and absence', () => {
  assert.deepEqual(
    [...parseScopes('users:read, users:read.email ,team:read')].sort(),
    ['team:read', 'users:read', 'users:read.email']);
  assert.equal(parseScopes(null).size, 0);
  assert.equal(parseScopes('').size, 0);
});

FAQ

Why is there no missing_scope error for this?

Because nothing was refused. users:read entitles the token to profiles, and the response is a complete, valid profile for that grant with the email key simply not included. missing_scope is returned when a method is refused; here the method succeeded and answered a narrower question than you thought you asked.

Does users:read.email work on its own?

No. It extends the profile read rather than replacing it, so the token needs users:read as well. Add both, and remember that the email scope needs a written justification if the app is submitted to the Marketplace.

I added the scope and nothing changed. Why?

The deployed token still holds the grant it was minted with at install time. Editing Bot Token Scopes only affects what the next install requests, so the sequence is: add the scope, reinstall the app, then replace the token in your configuration. Skipping the third step is the usual reason a fix appears not to work.

Why does users.lookupByEmail say users_not_found?

Because without the scope the lookup cannot see email addresses to match against, and Slack answers as though no such user exists rather than as though you may not ask. Treat a users_not_found for an address you can verify by hand as evidence of the scope gap, not of a missing member.

Some members still have no email after the fix. Is that a bug?

Usually not. Guest accounts may have no address, and some workspaces and Grid orgs hide email by admin policy even when the scope is granted. That is why the script reports a partial census as its own state: the repair for it is to handle a missing email per member, not to reinstall again.

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.