CF Access → GitHub IdP runbook

When nest-firebase.thephenom.app (or any other CF Access surface gated by the N.E.S.T. Team by Phenom.earth GitHub App as the SSO IdP) returns “failed to fetch user/group from identity provider” after a successful GitHub consent, this page maps the failure to its root cause and the fix.

TL;DR. “Failed to fetch user/group from identity provider” on a CF Access ↔ GitHub-App SSO flow is almost never a GitHub-side problem. It almost always means the CF Access IdP config is missing one or more of: client_secret, github_organization, or the right user-level OAuth permission grant on the GitHub App side. CF’s API never echoes the secret back, so a GET on the IdP looks “fine” when it isn’t. Diagnose with a live PUT; verify with an Interceptor sign-in to the gated app.

The error

failed to fetch user/group from identity provider

Rendered by *.cloudflareaccess.com after a successful round-trip through GitHub OAuth (the user authorized the app), but CF Access could not complete the post-callback identity exchange.

Defects we’ve seen produce this exact message

Any one (or combination) of:

# Defect Surface where it lives How CF API shows it
1 No client_secret on file in the CF Access IdP accounts/<acct>/access/identity_providers/<id> config CF API does NOT echo client_secret in GET responses,sanitized regardless of whether it’s set. The absence in a GET is not proof of absence on file. Only a live OAuth probe distinguishes.
2 github_organization missing / wrong on the CF Access IdP Same,config.github_organization field If empty, CF can’t enumerate org membership for the Access Policy filter. Set to the org’s login slug (case matters per the API; the org’s display name is irrelevant).
3 GitHub App lacks user-level Email addresses: Read permission, OR the org install hasn’t re-accepted the new permission GitHub App settings → Account permissions; installation accept page `gh api /orgs//installations
4 Stale client_secret (one rotated on the GitHub App side but never re-pasted into CF) Same as #1 Indistinguishable from #1 via API. Same fix path: generate a fresh secret on the App, PUT to CF.

Diagnose

  1. Capture both sides of state. All probes are read-only.

    # GitHub side
    gh api /orgs/Phenom-earth/installations \
      | jq '.installations[] | select(.app_id == 3085870)'
    # → permissions, repository_selection, updated_at
    
    # CF side (export a CF API token with Access:Read scope first)
    export CF_TOKEN="$(op read 'op://Claude/cloudflare-api-token/password')"
    curl -s -H "Authorization: Bearer $CF_TOKEN" \
      "https://api.cloudflare.com/client/v4/accounts/<acct>/access/identity_providers/<idp-id>" \
      | jq '.result | {name, type, config: {client_id, github_organization, scopes, redirect_url}}'
    

    Cross-reference the IdP’s client_id against the GitHub App’s client_id. If they match, the IdP is wired to the right App.

  2. Don’t trust the apparent state of client_secret from a GET. CF redacts it. The only way to know if a secret is on file is to actually attempt an OAuth exchange.

  3. Reproduce the failure with Interceptor, fresh cookies on the team domain, navigate to the gated app. Capture the URL where the error renders, the exact error string, and any CF Ray ID in the page.

Fix

The fix nearly always combines a GitHub-side action and a CF-side PUT.

A. GitHub App (App owner / org admin)

  1. Generate a new client secret. App settings → Client secrets → Generate a new client secret. You see the value exactly once. Don’t paste it in chat or any persistent surface. Drop it straight into 1Password (op item edit ... or the GUI), then refer to the 1Password path in further steps.
  2. Ensure user-level permission Email addresses: Read is enabled. Permissions & events → Account permissions → Email addresses → Read-only → Save.
  3. Re-accept the install on the org page. GitHub surfaces a “Pending permission updates” banner at /organizations/<org>/settings/installations/<install-id> after step 2. Click through.

B. Cloudflare Access (CF API)

PUT the IdP with the fresh secret and the org slug. The cleanest pattern keeps the secret out of argv via jq’s --arg:

export CF_TOKEN="$(op read 'op://Claude/cloudflare-api-token/password')"
export GH_APP_SECRET="$(op read 'op://Claude/nest-team-github-app-secret/password')"   # whatever 1P path holds the new value
ACCT="<account-id>"
IDP="<idp-uuid>"

PAYLOAD=$(jq -n --arg cs "$GH_APP_SECRET" '{
  name: "<existing-or-renamed-idp-name>",
  type: "github",
  config: {
    client_id: "<app-client-id>",
    client_secret: $cs,
    github_organization: "Phenom-earth"
  }
}')

curl -s -X PUT \
  -H "Authorization: Bearer $CF_TOKEN" \
  -H "Content-Type: application/json" \
  "https://api.cloudflare.com/client/v4/accounts/$ACCT/access/identity_providers/$IDP" \
  --data "$PAYLOAD" \
  | jq '. | (if .result and .result.config and .result.config.client_secret then .result.config.client_secret = "<REDACTED>" else . end)'

unset GH_APP_SECRET PAYLOAD

Expected response: success: true, errors: []. A success message you may also see: Finish enterprise enablement by going to https://<team>.cloudflareaccess.com/cdn-cgi/access/enterprise-setup/<token>. This nag is a CF Zero Trust account-onboarding wizard, not an upsell to a paid tier and not a blocker for the gated app to work. The URL itself is currently broken on CF’s side (returns 500 even with a fresh token) for some accounts. Skip it; live-probe the actual flow.

Verify

Live-probe via the Interceptor skill on the gated URL (e.g. nest-firebase.thephenom.app):

  1. interceptor cookies delete "https://<team>.cloudflareaccess.com/" CF_Authorization
  2. interceptor cookies delete "https://<team>.cloudflareaccess.com/" CF_AppSession
  3. interceptor open "https://<gated-app>/"
  4. Expect: GitHub consent screen with scope=user:email,read:org, the new permission listed as New request: Read-only access to Email addresses.
  5. Click Authorize (GitHub disables the button for programmatic input on permission-change flows; it must be a real click in Chrome).
  6. Expect: CF callback completes, CF_Authorization cookie set on the gated app’s domain, JWT contains idp.id == <idp-uuid> and idp.type == "github".

Anti-patterns

  • wrangler secret delete then wrangler secret put to “reset” a Worker secret. Atomic wrangler secret put overwrites in place; the delete is what triggers the SecurityPipeline hook for nothing. Same applies to other secret-store CLIs: use atomic update, not delete-then-create.
  • Pasting a fresh client secret into the chat surface. Once it lands in a conversation transcript, treat as compromised and rotate again. Put the new value directly into 1Password after the immediate fix lands. Bake this into the workflow: 1Password is the destination, not chat.
  • Trusting the has_client_secret field implied by a GET on the IdP. CF doesn’t tell you, by API or by GET response, whether a secret is on file. Only an OAuth round-trip distinguishes.
  • Conflating “enterprise enablement” with “upgrade to Enterprise tier”. The CF API message is misleading. The Zero Trust account-onboarding wizard is free; the paid Enterprise tier is a sales-gated contract. They share a word and that’s where the confusion lives.