Skip to content

Diagnostic GitHub API

org lists silently omit SSO-enforced organizations

The user belongs to six organizations. GET /user/orgs returns four. Status 200, valid JSON, no errors array, nothing in the body that hints anything is absent. The two organizations that enforce SAML single sign-on for a token that was never authorized against them are simply not in the list, and the only place GitHub mentions it is a response header called X-GitHub-SSO.

Read-only token Python and Node.js Tests included
White signs on a metal rack
Photo by Anna Auza on Unsplash
The short answer

Read the x-github-sso response header on every cross-organization list, on every page, including the ones that returned 200. Its partial form looks like X-GitHub-SSO: partial-results; organizations=21955855,20582480 and names the database IDs of the organizations that were withheld.

The presence of that header on a successful response is the finding. Resolve each ID with GET /organizations/{id} where you have access, then either authorize the token for those organizations or run per-organization queries with credentials scoped to each.

The problem in plain words

An inventory script is trusted in proportion to how boring it is, and this one is very boring: it lists organizations, lists their repositories, and writes a row per repository. It has run nightly for two years. It has never errored. It has also never mentioned the two organizations that hold the regulated workloads, because those enforce SAML and the token was authorized for the other four.

What makes this worse than an outright failure is that partial results propagate. The inventory feeds a compliance report, the compliance report feeds a dashboard, and every consumer downstream treats a 200 as a complete answer, because a 200 is a complete answer everywhere else. Nobody is looking for the gap, and the gap has no shape: the missing organizations are not returned as empty, or as errors, or as anything at all.

Token lists theorgsGET /user/orgsTwo enforceSAMLtoken notauthorizedGitHub answers200four orgs, validJSONOmission in aheaderX-GitHub-SSOInventory underreportsnightly, for years
Every step is a success. The inventory is wrong for two years and never logs a single warning.

Why it happens

The failure is per organization, and the request spans several. A SAML-enforcing organization requires the token to be explicitly authorized against its identity provider. On a single-organization endpoint that produces a clean 403 with an authorization URL. On a cross-organization listing, failing the whole request because one of six organizations is unauthorized would break the endpoint for everyone, so GitHub returns what it may and flags the rest.

The flag is in a header, and headers are where signals go to die. Most HTTP clients hand you the parsed body and put the headers somewhere you have to ask for. Every SDK that returns a plain array of organizations from this call has already discarded the header before your code sees it, which is why the omission survives so long.

The header names IDs, not logins. organizations=21955855,20582480 is not something anyone recognises. Turning it into names costs another request per ID, and where the token cannot see that organization at all, even that lookup can fail — so the honest report sometimes contains a number and the sentence "this ID was withheld and could not be resolved".

Authorization lapses on a schedule you do not control. A token authorized today stops being authorized when the SAML session policy says so, or when an administrator revokes it, or when the identity provider configuration changes. The script that worked last month starts returning fewer organizations this month with no code change and no error.

The fix, as a flow

The script reads a header on every page rather than the body on the first, because the omission is never in the JSON: a partial answer and a complete one are byte for byte the same shape.

Header read on every pagenot the body, not page oneNo header on a 200the list is the whole listpartial-resultsorg ids that were withheldrequired, with a urlat least it failed loudlyA value you cannot parsenever read as clean
A header value the parser does not recognise is the dangerous one: read as absence it turns a partial answer into a clean bill of health.

How to fix it

Call the listing and keep the response object, not just the body

GET /user/orgs?per_page=100. The instinct is to return response.json() and move on; that discards the only evidence there is. Whatever wrapper you use, make sure the headers survive as far as the code that decides whether the answer is complete.

Read x-github-sso on every page, not just the first

The header is attached per response. A three-page listing is three responses and the flag can appear on any of them, so a check that only inspects page one is a check that works until the organization list grows past a hundred entries.

Parse the two forms apart

partial-results; organizations=<ids> arrives on a 200 and means the body is incomplete. required; url=<authorization url> arrives on a 403 and means the request failed outright, which is the loud, easy version. Anything else you cannot parse must be treated as suspect rather than folded into "no header" — a value nobody understood is still a value GitHub sent.

Resolve the withheld IDs into names

GET /organizations/{id} turns each database ID into a login you can put in a report. Expect some of them to fail: the token that could not list the organization may not be able to read it either, and a report that says 21955855 and admits it could not name it is more useful than one that quietly drops the row.

Make partial an error condition in anything that inventories

For a dashboard, a partial list is a degraded view. For an audit, an inventory or a security report, a partial list is a wrong answer wearing a 200. Exit non-zero, name the withheld IDs, and authorize the token for those organizations — or accept the partition and run a separately scoped credential per organization, which is the honest architecture when the organizations are genuinely under different administration.

How to check it worked

Re-run after authorizing the token. The header should be absent, and the organization count should match what the user sees on their profile.

python3 github_sso_partial_results.py
# complete         6 organization(s), no partial-results header on any page

The full code

One paginated GET, read with a token that needs nothing beyond read:org. The two pure functions are the header parser and the verdict, and they carry the whole note: parsing decides what GitHub said, and the verdict decides what to do about a header that was sent but not understood — which is the case that turns a silent omission into a clean bill of health if you get it wrong.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 24 GitHub API fixes, free and open source.
github_sso_partial_results.py
"""Find organizations that GitHub withheld from a 200 because of SAML SSO.

Read only. GET requests and nothing else: read:org is enough. The repair is
printed, never performed, because this script holds a credential that spans
several organizations.
"""
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("github_sso_partial_results")

API = "https://api.github.com"
UA = "github-sso-partial-results/1.0"


def parse_sso(value):
    """Parse an X-GitHub-SSO header value. Pure, so both forms are testable.

    Two shapes exist. On a 200 the partial form names the organizations that were
    withheld from the body:

        partial-results; organizations=21955855,20582480

    On a 403 the required form carries the URL that authorizes the token:

        required; url=https://github.com/orgs/acme/sso?authorization_request=...

    A value that matches neither is reported as "unknown" rather than folded into
    "none". A header nobody parsed is still a header GitHub sent, and reading it
    as absence is exactly how a partial answer becomes a clean bill of health.
    """
    raw = (value or "").strip()
    if not raw:
        return {"kind": "none", "organizations": [], "url": None}

    parts = [p.strip() for p in raw.split(";") if p.strip()]
    kind = parts[0].lower()
    orgs, url = [], None
    for part in parts[1:]:
        name, sep, val = part.partition("=")
        if not sep:
            continue
        name = name.strip().lower()
        if name == "organizations":
            orgs = [o.strip() for o in val.split(",") if o.strip()]
        elif name == "url":
            url = val.strip()

    if kind not in ("partial-results", "required"):
        kind = "unknown"
    return {"kind": kind, "organizations": orgs, "url": url}


def verdict(status, sso, listed):
    """Decide what one response means. Pure. Returns (state, detail).

    The header outranks the status code: a 200 carrying partial-results is a
    failure and a 403 carrying required is at least an honest one.
    """
    kind = sso.get("kind")

    if kind == "partial-results":
        hidden = sso.get("organizations") or []
        return ("partial",
                "%d organization(s) in the body and %d withheld (%s). The status "
                "is 200 and the JSON is valid; the answer is not."
                % (listed, len(hidden), ", ".join(hidden) or "unnamed"))

    if kind == "required":
        return ("authorization-required",
                "the token is not SSO-authorized and GitHub said so out loud. "
                "Authorize it at %s" % (sso.get("url") or "the org's SSO page",))

    if kind == "unknown":
        return ("unreadable",
                "an X-GitHub-SSO header was sent and this parser did not "
                "understand it. Treat that as partial, never as clean, and read "
                "the raw value before trusting the list.")

    if status == 403:
        return ("forbidden",
                "403 with no X-GitHub-SSO header, so this is not SSO. Look at "
                "org OAuth app restrictions, an IP allow list, or a missing "
                "read:org scope instead.")
    if status != 200:
        return ("unexpected", "HTTP %s" % (status,))

    return ("complete", "%d organization(s), no partial-results header" % (listed,))


def get(session, url, **params):
    return session.get(url, params=params, timeout=30)


def list_orgs(session, api):
    """Page /user/orgs, returning (organizations, worst response seen).

    The header is attached per response, so every page is inspected. The first
    page carrying a partial-results header wins, because one hole makes the
    whole list partial.
    """
    orgs = []
    finding = {"status": 200, "sso": {"kind": "none", "organizations": [], "url": None}}
    page = 1
    while True:
        r = get(session, api + "/user/orgs", per_page=100, page=page)
        sso = parse_sso(r.headers.get("x-github-sso"))
        if sso["kind"] != "none" and finding["sso"]["kind"] == "none":
            finding = {"status": r.status_code, "sso": sso}
        if r.status_code != 200:
            finding["status"] = r.status_code
            break
        items = r.json()
        orgs.extend(items)
        if len(items) < 100:
            break
        page += 1
    return orgs, finding


def resolve(session, api, org_id):
    """Turn a withheld organization ID into a login, or admit it cannot."""
    r = get(session, "%s/organizations/%s" % (api, org_id))
    if r.status_code != 200:
        return None
    return r.json().get("login")


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--api", default=API,
                    help="API host, for GitHub Enterprise Server")
    ap.add_argument("--resolve-ids", action="store_true",
                    help="one extra GET per withheld organization, to name it")
    args = ap.parse_args()

    token = os.environ.get("GITHUB_TOKEN")
    if not token:
        log.error("set GITHUB_TOKEN (read:org is enough)")
        return 2

    session = requests.Session()
    session.headers.update({
        "Authorization": "Bearer " + token,
        "Accept": "application/vnd.github+json",
        "X-GitHub-Api-Version": "2022-11-28",
        "User-Agent": UA,
    })

    orgs, finding = list_orgs(session, args.api)
    state, detail = verdict(finding["status"], finding["sso"], len(orgs))

    if state == "complete":
        log.info("%-22s %s", state, detail)
        return 0

    log.warning("%-22s %s", state, detail)
    log.warning("  visible: %s",
                ", ".join(str(o.get("login")) for o in orgs) or "none")

    if args.resolve_ids and finding["sso"]["kind"] == "partial-results":
        for org_id in finding["sso"]["organizations"]:
            name = resolve(session, args.api, org_id)
            log.warning("  withheld: %s (%s)", org_id,
                        name or "could not be resolved with this token either")

    log.warning("  repair: authorize this token for the withheld organizations "
                "in your GitHub settings under SSO, or run one credential per "
                "organization and stop asking a single token a question it "
                "cannot answer completely.")
    return 1


if __name__ == "__main__":
    sys.exit(main())
github-sso-partial-results.mjs
/**
 * Find organizations that GitHub withheld from a 200 because of SAML SSO.
 *
 * Read only. GET requests and nothing else: read:org is enough. The repair is
 * printed, never performed.
 */
const API = 'https://api.github.com';
const UA = 'github-sso-partial-results/1.0';

/**
 * Parse an X-GitHub-SSO header value. Pure, so both forms are testable.
 *
 * On a 200:  partial-results; organizations=21955855,20582480
 * On a 403:  required; url=https://github.com/orgs/acme/sso?authorization_request=...
 *
 * Anything else is "unknown" rather than "none": a header nobody parsed is still
 * a header GitHub sent, and reading it as absence is how a partial answer becomes
 * a clean bill of health.
 */
export function parseSso(value) {
  const raw = String(value ?? '').trim();
  if (!raw) return { kind: 'none', organizations: [], url: null };

  const parts = raw.split(';').map((p) => p.trim()).filter(Boolean);
  let kind = (parts[0] ?? '').toLowerCase();
  let organizations = [];
  let url = null;
  for (const part of parts.slice(1)) {
    const at = part.indexOf('=');
    if (at < 0) continue;
    const name = part.slice(0, at).trim().toLowerCase();
    const val = part.slice(at + 1).trim();
    if (name === 'organizations') {
      organizations = val.split(',').map((o) => o.trim()).filter(Boolean);
    } else if (name === 'url') {
      url = val;
    }
  }
  if (kind !== 'partial-results' && kind !== 'required') kind = 'unknown';
  return { kind, organizations, url };
}

/**
 * Decide what one response means. Pure. Returns [state, detail]. The header
 * outranks the status code.
 */
export function verdict(status, sso, listed) {
  const kind = sso.kind;

  if (kind === 'partial-results') {
    const hidden = sso.organizations ?? [];
    return ['partial',
      `${listed} organization(s) in the body and ${hidden.length} withheld ` +
      `(${hidden.join(', ') || 'unnamed'}). The status is 200 and the JSON is ` +
      'valid; the answer is not.'];
  }

  if (kind === 'required') {
    return ['authorization-required',
      'the token is not SSO-authorized and GitHub said so out loud. Authorize ' +
      `it at ${sso.url ?? "the org's SSO page"}`];
  }

  if (kind === 'unknown') {
    return ['unreadable',
      'an X-GitHub-SSO header was sent and this parser did not understand it. ' +
      'Treat that as partial, never as clean, and read the raw value before ' +
      'trusting the list.'];
  }

  if (status === 403) {
    return ['forbidden',
      '403 with no X-GitHub-SSO header, so this is not SSO. Look at org OAuth ' +
      'app restrictions, an IP allow list, or a missing read:org scope instead.'];
  }
  if (status !== 200) return ['unexpected', `HTTP ${status}`];

  return ['complete', `${listed} organization(s), no partial-results header`];
}

function headers(token) {
  return {
    Authorization: `Bearer ${token}`,
    Accept: 'application/vnd.github+json',
    'X-GitHub-Api-Version': '2022-11-28',
    'User-Agent': UA,
  };
}

async function get(token, url, params = {}) {
  const u = new URL(url);
  for (const [k, v] of Object.entries(params)) u.searchParams.set(k, v);
  return fetch(u, { headers: headers(token) });
}

export async function listOrgs(token, api = API) {
  const orgs = [];
  let finding = { status: 200, sso: { kind: 'none', organizations: [], url: null } };
  let page = 1;
  for (;;) {
    const res = await get(token, `${api}/user/orgs`, { per_page: 100, page });
    const sso = parseSso(res.headers.get('x-github-sso'));
    if (sso.kind !== 'none' && finding.sso.kind === 'none') {
      finding = { status: res.status, sso };
    }
    if (res.status !== 200) { finding.status = res.status; break; }
    const items = await res.json();
    orgs.push(...items);
    if (items.length < 100) break;
    page += 1;
  }
  return { orgs, finding };
}

async function main() {
  const token = process.env.GITHUB_TOKEN;
  if (!token) {
    console.error('set GITHUB_TOKEN (read:org is enough)');
    process.exitCode = 2;
    return;
  }

  const { orgs, finding } = await listOrgs(token);
  const [state, detail] = verdict(finding.status, finding.sso, orgs.length);

  if (state === 'complete') {
    console.log(`${state.padEnd(22)} ${detail}`);
    return;
  }

  console.warn(`${state.padEnd(22)} ${detail}`);
  console.warn(`  visible: ${orgs.map((o) => o.login).join(', ') || 'none'}`);

  if (process.argv.includes('--resolve-ids') &&
      finding.sso.kind === 'partial-results') {
    for (const id of finding.sso.organizations) {
      const res = await get(token, `${API}/organizations/${id}`);
      const name = res.status === 200 ? (await res.json()).login : null;
      console.warn(`  withheld: ${id} ` +
        `(${name ?? 'could not be resolved with this token either'})`);
    }
  }

  console.warn('  repair: authorize this token for the withheld organizations in ' +
               'your GitHub settings under SSO, or run one credential per ' +
               'organization and stop asking a single token a question it cannot ' +
               'answer completely.');
  process.exitCode = 1;
}

// Only run when invoked directly. The test file imports this module, and without
// the guard main() would run there too, fail on the missing token, and set a
// non-zero exit code that fails the whole test file even as every test passes.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The test that matters is the one for a header the parser did not recognise. Every other case is arithmetic; that one is a judgement, and the wrong judgement is silent. If an unfamiliar X-GitHub-SSO value falls through to complete, the script reports a clean inventory on exactly the day GitHub changed the header, which is the failure this whole note is about happening a second time inside the tool built to catch it.

test_github_sso_partial_results.py
from github_sso_partial_results import parse_sso, verdict


def test_partial_form_yields_the_withheld_ids():
    sso = parse_sso("partial-results; organizations=21955855,20582480")
    assert sso["kind"] == "partial-results"
    assert sso["organizations"] == ["21955855", "20582480"]
    assert sso["url"] is None


def test_required_form_yields_the_authorization_url():
    sso = parse_sso("required; url=https://github.com/orgs/acme/sso?x=1")
    assert sso["kind"] == "required"
    assert sso["url"] == "https://github.com/orgs/acme/sso?x=1"


def test_absent_and_blank_headers_are_the_same_nothing():
    assert parse_sso(None)["kind"] == "none"
    assert parse_sso("")["kind"] == "none"
    assert parse_sso("   ")["kind"] == "none"


def test_an_unrecognised_value_is_never_read_as_absence():
    # The whole point. A header GitHub sent that this parser did not understand
    # must not fall through to "clean".
    sso = parse_sso("some-future-directive; organizations=1")
    assert sso["kind"] == "unknown"
    assert verdict(200, sso, 4)[0] == "unreadable"


def test_a_200_with_partial_results_is_a_failure():
    sso = parse_sso("partial-results; organizations=21955855,20582480")
    state, detail = verdict(200, sso, 4)
    assert state == "partial"
    assert "4 organization(s)" in detail
    assert "2 withheld" in detail
    assert "21955855" in detail


def test_a_403_with_the_required_form_is_the_loud_version():
    sso = parse_sso("required; url=https://github.com/orgs/acme/sso")
    state, detail = verdict(403, sso, 0)
    assert state == "authorization-required"
    assert "https://github.com/orgs/acme/sso" in detail


def test_a_403_without_the_header_is_not_an_sso_problem():
    state, detail = verdict(403, parse_sso(None), 0)
    assert state == "forbidden"
    assert "read:org" in detail


def test_a_clean_200_is_complete():
    state, detail = verdict(200, parse_sso(None), 6)
    assert state == "complete"
    assert "6 organization(s)" in detail


def test_the_header_outranks_the_status_code():
    # A partial-results header on a 200 is worse news than a 403, so it must not
    # be reachable only through the non-200 branch.
    sso = parse_sso("partial-results; organizations=99")
    assert verdict(200, sso, 1)[0] == "partial"
    assert verdict(500, sso, 1)[0] == "partial"
github-sso-partial-results.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { parseSso, verdict } from './github-sso-partial-results.mjs';

test('partial form yields the withheld ids', () => {
  const sso = parseSso('partial-results; organizations=21955855,20582480');
  assert.equal(sso.kind, 'partial-results');
  assert.deepEqual(sso.organizations, ['21955855', '20582480']);
  assert.equal(sso.url, null);
});

test('required form yields the authorization url', () => {
  const sso = parseSso('required; url=https://github.com/orgs/acme/sso?x=1');
  assert.equal(sso.kind, 'required');
  assert.equal(sso.url, 'https://github.com/orgs/acme/sso?x=1');
});

test('absent and blank headers are the same nothing', () => {
  assert.equal(parseSso(null).kind, 'none');
  assert.equal(parseSso('').kind, 'none');
  assert.equal(parseSso('   ').kind, 'none');
});

test('an unrecognised value is never read as absence', () => {
  const sso = parseSso('some-future-directive; organizations=1');
  assert.equal(sso.kind, 'unknown');
  assert.equal(verdict(200, sso, 4)[0], 'unreadable');
});

test('a 200 with partial results is a failure', () => {
  const sso = parseSso('partial-results; organizations=21955855,20582480');
  const [state, detail] = verdict(200, sso, 4);
  assert.equal(state, 'partial');
  assert.match(detail, /4 organization\(s\)/);
  assert.match(detail, /2 withheld/);
  assert.match(detail, /21955855/);
});

test('a 403 with the required form is the loud version', () => {
  const sso = parseSso('required; url=https://github.com/orgs/acme/sso');
  const [state, detail] = verdict(403, sso, 0);
  assert.equal(state, 'authorization-required');
  assert.match(detail, /orgs\/acme\/sso/);
});

test('a 403 without the header is not an sso problem', () => {
  const [state, detail] = verdict(403, parseSso(null), 0);
  assert.equal(state, 'forbidden');
  assert.match(detail, /read:org/);
});

test('a clean 200 is complete', () => {
  const [state, detail] = verdict(200, parseSso(null), 6);
  assert.equal(state, 'complete');
  assert.match(detail, /6 organization\(s\)/);
});

test('the header outranks the status code', () => {
  const sso = parseSso('partial-results; organizations=99');
  assert.equal(verdict(200, sso, 1)[0], 'partial');
  assert.equal(verdict(500, sso, 1)[0], 'partial');
});

FAQ

Why does GitHub return 200 when part of the answer is missing?

Because the endpoint spans organizations and the authorization is per organization. Failing the whole call because one of six organizations enforces SSO would make the endpoint useless for everyone in that position, so GitHub returns what the token may see and records the omission in the X-GitHub-SSO response header.

What exactly does the X-GitHub-SSO header look like?

Two forms. On a successful but incomplete response it reads partial-results; organizations=21955855,20582480, naming the database IDs of the withheld organizations. On a rejected single-organization request it reads required; url=... and carries the link that authorizes the token. The first is the dangerous one because it accompanies a 200.

My SDK returns an array of organizations. Where do I get the header?

You often cannot, which is the practical problem. Most clients expose a lower-level request method or a response hook that keeps the raw headers; use that for cross-organization listings specifically. If the wrapper genuinely discards headers, this one call is worth making with a plain HTTP client.

The header names IDs. How do I turn those into organization names?

GET /organizations/{id} resolves a database ID to a login. Expect some to fail: a token that could not list the organization may not be able to read it either. Report the bare ID in that case rather than dropping the row, since the ID is still enough for an administrator to identify.

Should a partial result fail the job or just warn?

It depends on what the job is for. A dashboard can show a degraded view. An inventory, an audit or a security report cannot: a partial answer there is a wrong answer with a 200 attached, and it will be quoted downstream by things that have no idea anything was withheld. Exit non-zero and name the IDs.

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.