10 min read

Slack has_2fa Missing from users.list? Why It Means Unknown, Not Disabled

Slack's users.list omits has_2fa unless an admin makes the call. Learn why a missing value must mean unknown, not 2FA off, and how to model it in a connector.

A Slack connector calls users.list, reads has_2fa, and reports “2FA disabled” for every person in the workspace. Nobody actually turned 2FA off. The field was never in the response, and the code turned “not there” into false.

Slack’s users.list only includes has_2fa when the caller is a workspace admin. Slack’s user-object reference says the field is “only visible if the user executing the call is an admin,” and bot-token calls commonly omit it. A missing has_2fa therefore means “unknown,” not “2FA disabled.” Model it as three states (enabled, disabled, unknown), never default it to false, and remember that even a visible false only describes Slack’s own 2FA. Members who sign in through SSO have their MFA in the identity provider, not in has_2fa.

Why has_2fa is missing from your users.list response

Slack’s user object reference describes has_2fa as a Boolean that “Describes whether two-factor authentication is enabled for this user. Only visible if the user executing the call is an admin.” The same page warns that user objects vary by API and context, and that data not supplied “may not be present at all, may be null, or may contain an empty string.”

The confusion starts on a different page. The users.list reference shows "has_2fa": false on its example members with no visibility caveat. If that is the page you build from, it is reasonable to assume every member always carries the field. The caveat lives only on the user-object page.

Bot tokens are where most people hit this. In slack-go issue #1121, opened on November 1, 2022, the author reports that calling users.list with a bot token (xoxb-) and the users:read scope returns members without has_2fa. The issue author also quotes a reply they say came from Slack support: “has_2fa parameter only shows if the person calling that endpoint is an Admin or Owner, and using a user token (starting with xoxp), and not a bot token (starting with xoxb).” Treat that as the issue author’s second-hand account, not as Slack documentation. Slack’s published docs say only “admin.”

One more trap: installing an app as an admin does not make every token from that install an admin-user token. A bot token from an admin-approved install is still a bot token.

Here is what the difference looks like. These two members are illustrative, not recorded Slack responses:

{
  "members": [
    { "id": "UFAKE0001", "team_id": "TFAKE0001", "is_admin": false, "has_2fa": false },
    { "id": "UFAKE0002", "team_id": "TFAKE0001", "is_admin": false }
  ]
}

The first member says Slack-native 2FA is off, as seen by this caller. The second says nothing about 2FA at all.

Why “missing” must not become “false”

If a connector turns absence into false, every downstream system inherits a claim nobody verified. Customers get a wall of “2FA disabled” findings. Security teams spend time chasing people who may already have 2FA. When the findings turn out to be wrong, the next real finding gets less attention. Missing a real gap is bad. Flagging hundreds of gaps that aren’t real teaches customers to ignore your findings.

A widely used SDK got this wrong for years

This is a common trap, not a careless one. In slack-go, User.Has2FA was a plain Go bool, so a missing field decoded to false. Issue #1121 stayed open from November 2022 until commit 2150449 on March 29, 2026, titled “fix(users)!: change Has2FA from bool to *bool.” The commit message says that with a bot token, users.list omits has_2fa, and with a plain bool “this was indistinguishable from explicitly false, causing all users to appear as not having 2FA enabled.” The change shipped in v0.21.0 on April 5, 2026, whose release notes say: “nil means absent/unknown (bot tokens), false means explicitly disabled.”

The struct change is one line:

// before v0.21.0
Has2FA bool `json:"has_2fa"`

// v0.21.0 and later
Has2FA *bool `json:"has_2fa,omitempty"`

If you upgraded and your build broke, that is the reason. Handle the nil case explicitly instead of dereferencing:

func twoFactorState(u slack.User) string {
	switch {
	case u.Has2FA == nil:
		return "unknown"
	case *u.Has2FA:
		return "enabled"
	default:
		return "disabled"
	}
}

The language trap

The same mistake is easy to make in any stack:

  • Go: a non-pointer bool field decodes a missing key to its zero value, false.
  • Python: member.get("has_2fa", False) picks a default that reads as a real answer.
  • TypeScript/JavaScript: member.has_2fa ?? false or !!member.has_2fa does the same thing.
  • Databases: a BOOLEAN NOT NULL DEFAULT FALSE column forces you to invent a value when you have none.

The official Node SDK’s AdminUsersListResponse type marks the field optional (has_2fa?: boolean), which is correct, but the compiler will not stop you from collapsing it. This is the bug in TypeScript:

// Bug: absence silently becomes "disabled"
for (const member of response.members ?? []) {
  const twoFactorOn = member.has_2fa ?? false;
  if (!twoFactorOn) {
    findings.push({ userId: member.id, issue: '2FA disabled' });
  }
}

Getting a real has_2fa value

users.list with an admin caller

Slack documents has_2fa as visible when the user executing the call is an admin. In practice that means a user token that belongs to a workspace admin or owner. Visibility for one caller still does not guarantee a value on every member. In QCC’s Slack users.list sample, one consented live check with an admin/owner user token saw a boolean has_2fa on one returned entry while other entries omitted it. The retained evidence could not show whose value that was or why the others were missing. That single check does not prove that an admin token exposes every member’s value, so check presence per member rather than assuming it.

admin.users.list on Enterprise orgs

On an Enterprise plan, admin.users.list is the org-level option. It requires a user token with the admin.users:read scope, and the app must be installed by an admin or owner of the Enterprise organization on the entire org, not on a single workspace. The documented example response includes both has_2fa and has_sso on each user. We have not tested this method live, so treat its field coverage as documented, not confirmed.

users.list admin.users.list
Plan No Enterprise-only notice in its docs Enterprise plan only
Token Bot or user token User token
Scope users:read admin.users:read
Install Standard app install Org-wide install by an Enterprise org admin or owner
has_2fa Only visible to admin callers (user-object docs) Included in the documented example response
has_sso Not listed on the user object Included in the documented example response

Check scopes and token type before you trust absence

Before a scan’s 2FA data reaches a dashboard, record the context that explains it:

  • the token type (bot or user) and its granted scopes;
  • the caller’s role (admin, owner, or neither), for example from the caller’s own member record;
  • whether any member in the scan carried a non-null has_2fa.

If members came back but none carried the field, the token probably lacks admin visibility. Surface that as its own state, “visibility unverified,” not as a page of disabled users. QCC’s sample reports it as twoFactorFieldVisibility: "visibility_unverified".

What has_2fa does not tell you

It covers Slack-native 2FA only. Slack’s help center article on mandatory workspace two-factor authentication says “If you’re using SSO, 2FA should be set up through your identity provider,” and that “2FA in Slack will be turned off when a member connects, or binds, their SSO account.” A member who signs in through SAML SSO with strong MFA at the IdP can therefore show has_2fa: false. That is expected, and on Slack data alone it is not a finding.

two_factor_type only appears when has_2fa is true. Its value is app or sms. Its absence on a member with has_2fa: false or no has_2fa tells you nothing new.

A complete scan is not complete knowledge. “We completed the scan” and “we know every person’s MFA status” are different claims. A scan can follow every cursor to the end and still hold a pile of unknowns.

It is not a compliance verdict. has_2fa is one vendor-reported field, seen by one caller. It does not show that a SOC 2 control, an ISO 27001 requirement, or any internal MFA policy is met.

How to model it in your connector

Use three states: enabled, disabled, unknown. A value that is present but not a boolean (or null) means the payload is not what you expected, so fail the scan instead of coercing it. This normalizer follows the logic in QCC’s sample (src/collect.ts, src/types.ts):

/** Slack-native 2FA only. This is not SSO / identity-provider MFA. */
export type TwoFactorObservation = 'enabled' | 'disabled' | 'unknown';

export function classifyTwoFactor(value: unknown): TwoFactorObservation {
  if (value === true) return 'enabled';
  if (value === false) return 'disabled';
  if (value === undefined || value === null) return 'unknown';
  throw new Error('invalid_body: has_2fa is not a boolean');
}

export type TwoFactorFieldVisibility = 'observed' | 'visibility_unverified' | 'not_assessed';

export function fieldVisibility(
  members: readonly { has_2fa?: unknown }[],
): TwoFactorFieldVisibility {
  if (members.length === 0) return 'not_assessed';
  const anyPresent = members.some((m) => m.has_2fa !== undefined && m.has_2fa !== null);
  return anyPresent ? 'observed' : 'visibility_unverified';
}

Then carry unknown all the way through:

  • Storage: a nullable column or an enum, not BOOLEAN NOT NULL.
  • API: return "unknown" explicitly rather than omitting the field or sending false.
  • UI: a label such as “Unknown: not visible to this token.”
  • Findings: no finding on unknown. Raise a visibility gap on the integration instead.
  • Evidence: store token type, scopes, caller role, and whether the scan completed alongside each observation.

Tests to add

  • has_2fa: true returns enabled
  • has_2fa: false returns disabled
  • has_2fa absent returns unknown
  • has_2fa: null returns unknown
  • has_2fa: "yes" fails the scan as an invalid body
  • one page mixing present and absent fields keeps each member’s own state
  • every member missing the field reports visibility_unverified
  • the same member appearing on two pages with conflicting values fails the scan

Decision table

What the connector sees What it means What to store Should it raise a finding?
has_2fa: true Slack-native 2FA is on for this member enabled No
has_2fa: false, member does not use SSO Slack-native 2FA is off (as visible to this caller) disabled Possibly, per the customer’s policy
has_2fa: false, member signs in via SSO Expected: Slack turns its 2FA off when SSO is bound; MFA is at the IdP disabled (Slack-native) plus SSO context Not on Slack data alone; check the IdP
field absent Not visible to this caller/token; state unknown unknown No; flag a visibility gap instead
has_2fa: null Treat as not visible unknown No
non-boolean value (for example "yes") Unexpected payload fail the scan (invalid body) No; raise an integration error
no member in the scan carries the field Token likely lacks admin visibility visibility unverified No; prompt for an admin token or the Enterprise admin API
pagination did not finish (error on page N) Scan incomplete no observations No; mark the run incomplete

A note on that last row: Slack’s users.list docs say “Fewer than the requested number of items may be returned, even if the end of the users list hasn’t been reached.” A short page is not the end. Keep following response_metadata.next_cursor until it is empty, as described in Slack’s pagination guide.

A worked example

QCC’s Slack users.list sample (GitHub, MIT) is a small, read-only TypeScript connector built around three decisions:

  1. Missing has_2fa means unknown. Absent or null becomes unknown; a malformed value fails the run.
  2. A short page doesn’t end the scan. The collector follows cursors and rejects repeated cursors and malformed pagination metadata.
  3. A later failure returns no partial observations. If page 3 fails, pages 1 and 2 are not returned, so they can’t be mistaken for a full census.

Its output contract keeps the Slack-native scope visible in the field names:

Field Values
slackNativeTwoFactor enabled, disabled, unknown
twoFactorFieldVisibility observed, visibility_unverified, not_assessed

Know its limits. The tests use synthetic fixtures, not recorded Slack responses. Live evidence is one consented check covering a single page. It does not support Enterprise Grid org-level tokens, and it does not assess SSO or IdP MFA. It is a code-review sample, not production or customer work. Jeff has shipped Slack pagination and 429-retry work before; see his integration experience.

FAQ

Why doesn’t Slack’s users.list return has_2fa? It is only visible when the caller is an admin, so non-admin and bot-token calls typically omit it.

Does a missing has_2fa mean two-factor authentication is disabled? No. It means the value wasn’t visible to that token; treat it as unknown.

How do I get has_2fa for every user? Use an admin caller for users.list, or on an Enterprise org use admin.users.list with admin.users:read via an org-wide install. Verify field presence per member either way.

Why does has_2fa show false for users who use SSO? Slack turns off its own 2FA when a member binds SSO; their MFA is enforced by the identity provider.

What does two_factor_type mean? It is “app” or “sms,” and only present when has_2fa is true.

What changed in slack-go for has_2fa? In v0.21.0, User.Has2FA became *bool, so nil (absent) is distinct from false.

Can has_2fa prove our organization meets an MFA requirement? No. It reflects Slack-native 2FA as visible to one caller, not IdP MFA or a compliance verdict.

Building connectors customers can trust

If your product imports vendor data into security findings, small decisions like this one decide whether customers trust the result. QCC builds and maintains vendor integrations for security and compliance products inside your repository, with tests for the failure cases and notes on what the vendor actually returns. Try the integration queue for two weeks for $7,500, one active request at a time. See how the two-week trial works.

Start a paid trial

Have an integration that needs this kind of care?

Send the vendor or API, the outcome, and your stack. An inquiry does not start work or authorize access.