CF Access → GitHub IdP runbook
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/ |
| 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
-
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_idagainst the GitHub App’sclient_id. If they match, the IdP is wired to the right App. -
Don’t trust the apparent state of
client_secretfrom a GET. CF redacts it. The only way to know if a secret is on file is to actually attempt an OAuth exchange. -
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)
- 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. - Ensure user-level permission
Email addresses: Readis enabled. Permissions & events → Account permissions → Email addresses → Read-only → Save. - 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):
interceptor cookies delete "https://<team>.cloudflareaccess.com/" CF_Authorizationinterceptor cookies delete "https://<team>.cloudflareaccess.com/" CF_AppSessioninterceptor open "https://<gated-app>/"- Expect: GitHub consent screen with
scope=user:email,read:org, the new permission listed asNew request: Read-only access to Email addresses. - Click Authorize (GitHub disables the button for programmatic input on permission-change flows; it must be a real click in Chrome).
- Expect: CF callback completes,
CF_Authorizationcookie set on the gated app’s domain, JWT containsidp.id == <idp-uuid>andidp.type == "github".
Anti-patterns
wrangler secret deletethenwrangler secret putto “reset” a Worker secret. Atomicwrangler secret putoverwrites in place; thedeleteis 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_secretfield 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.
Related
- N.E.S.T. Access Gate (Cognito
nest-accessgroup): the Cognito-side gate that replaces this CF Access + GitHub IdP pattern onnest.thephenom.app(prod). The CF Access + GitHub IdP flow this page documents is still in place onnest-firebase.thephenom.app(the legacy snapshot surface). - Cognito Auth Flows in the SPA: the post-Cognito auth flows on the modern N.E.S.T. surface.
Feedback
Was this page helpful?
Glad to hear it! Please tell us how we can improve.
Sorry to hear that. Please tell us how we can improve.