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.
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.
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.
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.
"""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())
/**
* 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.
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()
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
- missing_scope names the scope you need
- next_cursor ignored, so one page is all you see
- a method that has been dead since November 2025
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.
- users.list method reference — Slack Docs
- Permission scopes — Slack Docs
- users.lookupByEmail method reference — Slack Docs
- Token types — Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.