[GH-ISSUE #6155] Reverse-proxy SSO callback hardcodes sub claim, ignores AuthUserIDClaim (breaks Azure AD / Entra ID) #12917

Closed
opened 2026-08-05 02:07:00 -04:00 by saavagebueno · 3 comments
Owner

Originally created by @erdelmaero on GitHub (May 15, 2026).
Original GitHub issue: https://github.com/netbirdio/netbird/issues/6155

Describe the problem

The reverse-proxy OAuth callback handler in management/server/http/handlers/proxy/auth.go extracts the user identifier from the OIDC ID token by hardcoding the sub claim. It does not honour the HttpConfig.AuthUserIDClaim value configured in management.json, even though the rest of NetBird's management does.

This breaks the reverse-proxy SSO flow on Azure AD / Microsoft Entra ID, because AAD's sub claim is a pairwise pseudonymous identifier (different value per (user, app) pair, looks like base64url), not a stable user identifier. The stable identifier on AAD is the oid claim (user Object ID, a UUID), which is what NetBird stores in users.id when syncing via the IdP integration. The lookup users WHERE id = <sub> therefore never matches an existing row, and every SSO attempt fails with user_not_found.

It works on IdPs where sub happens to be the stable user identifier (e.g. Keycloak, Auth0, Zitadel default config), which is presumably why the bug hasn't been hit by more users.

Source reference

In management/server/http/handlers/proxy/auth.go (verified on tag v0.71.0 and main):

func extractUserIDFromToken(ctx context.Context, provider *oidc.Provider, config nbgrpc.ProxyOIDCConfig, token *oauth2.Token) string {
    rawIDToken, ok := token.Extra(\"id_token\").(string)
    if !ok { ... }

    verifier := provider.Verifier(&oidc.Config{ClientID: config.ClientID})
    idToken, err := verifier.Verify(ctx, rawIDToken)
    if err != nil { ... }

    var claims struct {
        Subject string \`json:\"sub\"\`     // <-- hardcoded
    }
    if err := idToken.Claims(&claims); err != nil { ... }

    return claims.Subject
}

There is no path here that reads HttpConfig.AuthUserIDClaim from the management config. Contrast with the dashboard/management auth path, which does respect AuthUserIDClaim and lets AAD deployments configure it to oid.

To Reproduce

  1. Self-host NetBird (v0.71.0 verified).
  2. Configure Azure AD / Entra ID as the IdP. Set HttpConfig.AuthUserIDClaim: \"oid\" in management.json (per the recommended Azure setup so dashboard SSO uses the stable Object ID).
  3. Let the IdP sync run — confirm a row exists in the users table with id equal to your AAD oid.
  4. Create a reverse-proxy service in the dashboard and enable SSO for it. Grant access to a real user group that contains your user.
  5. Sign in via the reverse-proxy SSO flow in an incognito browser.

Expected behaviour

The user is recognised (their AAD oid is in the users table) and the access policy is evaluated. With a permitted group, the user should be granted access.

Actual behaviour

HTTP 403 "Access Denied". The proxy logs Session validation denied with denied_reason: user_not_found, user_id: <empty> (the empty user_id is because the gRPC response intentionally omits it for user_not_found).

With management at --log-level debug, the management container exposes the actual identifier it tried to look up:

DEBG [domain: <service-host>, user_id: <base64url-string-43-chars>,
      error: user: <base64url-string-43-chars> not found]
management/internals/shared/grpc/proxy.go:1263: ValidateSession: user not found

That <base64url-string-43-chars> is AAD's pairwise sub claim. The same row in the users table is keyed by the AAD oid (a UUID), so the two don't match and the lookup fails.

Are you using NetBird Cloud?

self-hosted

NetBird version

v0.71.0 (also reproduces on main as of today; the same hardcoded sub extraction is present)

Additional context

The dashboard SSO path works correctly because it uses HttpConfig.AuthUserIDClaim to pick the claim. Only the reverse-proxy callback in proxy/auth.go ignores the setting.

Proposed fix

Read the configured claim from HttpConfig.AuthUserIDClaim (or expose it via ProxyOIDCConfig) and use it instead of hardcoding sub. Pseudocode:

var claims map[string]interface{}
if err := idToken.Claims(&claims); err != nil { ... }
claimName := config.UserIDClaim  // pass through from HttpConfig.AuthUserIDClaim; default \"sub\"
v, _ := claims[claimName].(string)
return v

A nil/empty fallback to sub would preserve existing behaviour for IdPs where that's already correct.

  • #5343 (reverse-proxy SSO + group "All") — separate root cause but surfaces with the same 403.
  • #5753 (reverse-proxy missing client_secret in token exchange) — adjacent.
Originally created by @erdelmaero on GitHub (May 15, 2026). Original GitHub issue: https://github.com/netbirdio/netbird/issues/6155 ## Describe the problem The reverse-proxy OAuth callback handler in `management/server/http/handlers/proxy/auth.go` extracts the user identifier from the OIDC ID token by hardcoding the `sub` claim. It does **not** honour the `HttpConfig.AuthUserIDClaim` value configured in `management.json`, even though the rest of NetBird's management does. This breaks the reverse-proxy SSO flow on Azure AD / Microsoft Entra ID, because AAD's `sub` claim is a **pairwise pseudonymous identifier** (different value per `(user, app)` pair, looks like base64url), not a stable user identifier. The stable identifier on AAD is the `oid` claim (user Object ID, a UUID), which is what NetBird stores in `users.id` when syncing via the IdP integration. The lookup `users WHERE id = <sub>` therefore never matches an existing row, and every SSO attempt fails with `user_not_found`. It works on IdPs where `sub` happens to be the stable user identifier (e.g. Keycloak, Auth0, Zitadel default config), which is presumably why the bug hasn't been hit by more users. ## Source reference In `management/server/http/handlers/proxy/auth.go` (verified on tag `v0.71.0` and `main`): ```go func extractUserIDFromToken(ctx context.Context, provider *oidc.Provider, config nbgrpc.ProxyOIDCConfig, token *oauth2.Token) string { rawIDToken, ok := token.Extra(\"id_token\").(string) if !ok { ... } verifier := provider.Verifier(&oidc.Config{ClientID: config.ClientID}) idToken, err := verifier.Verify(ctx, rawIDToken) if err != nil { ... } var claims struct { Subject string \`json:\"sub\"\` // <-- hardcoded } if err := idToken.Claims(&claims); err != nil { ... } return claims.Subject } ``` There is no path here that reads `HttpConfig.AuthUserIDClaim` from the management config. Contrast with the dashboard/management auth path, which does respect `AuthUserIDClaim` and lets AAD deployments configure it to `oid`. ## To Reproduce 1. Self-host NetBird (v0.71.0 verified). 2. Configure Azure AD / Entra ID as the IdP. Set `HttpConfig.AuthUserIDClaim: \"oid\"` in `management.json` (per the recommended Azure setup so dashboard SSO uses the stable Object ID). 3. Let the IdP sync run — confirm a row exists in the `users` table with `id` equal to your AAD `oid`. 4. Create a reverse-proxy service in the dashboard and enable SSO for it. Grant access to a real user group that contains your user. 5. Sign in via the reverse-proxy SSO flow in an incognito browser. ## Expected behaviour The user is recognised (their AAD `oid` is in the `users` table) and the access policy is evaluated. With a permitted group, the user should be granted access. ## Actual behaviour HTTP 403 \"Access Denied\". The proxy logs `Session validation denied` with `denied_reason: user_not_found, user_id: <empty>` (the empty `user_id` is because the gRPC response intentionally omits it for `user_not_found`). With management at `--log-level debug`, the management container exposes the actual identifier it tried to look up: ``` DEBG [domain: <service-host>, user_id: <base64url-string-43-chars>, error: user: <base64url-string-43-chars> not found] management/internals/shared/grpc/proxy.go:1263: ValidateSession: user not found ``` That `<base64url-string-43-chars>` is AAD's pairwise `sub` claim. The same row in the `users` table is keyed by the AAD `oid` (a UUID), so the two don't match and the lookup fails. ## Are you using NetBird Cloud? self-hosted ## NetBird version v0.71.0 (also reproduces on `main` as of today; the same hardcoded `sub` extraction is present) ## Additional context The dashboard SSO path works correctly because it uses `HttpConfig.AuthUserIDClaim` to pick the claim. Only the reverse-proxy callback in `proxy/auth.go` ignores the setting. ## Proposed fix Read the configured claim from `HttpConfig.AuthUserIDClaim` (or expose it via `ProxyOIDCConfig`) and use it instead of hardcoding `sub`. Pseudocode: ```go var claims map[string]interface{} if err := idToken.Claims(&claims); err != nil { ... } claimName := config.UserIDClaim // pass through from HttpConfig.AuthUserIDClaim; default \"sub\" v, _ := claims[claimName].(string) return v ``` A `nil`/empty fallback to `sub` would preserve existing behaviour for IdPs where that's already correct. ## Related issues - #5343 (reverse-proxy SSO + group \"All\") — separate root cause but surfaces with the same 403. - #5753 (reverse-proxy missing `client_secret` in token exchange) — adjacent.
Author
Owner

@linear-code[bot] commented on GitHub (May 15, 2026):

NET-1181

<!-- gh-comment-id:4460597211 --> @linear-code[bot] commented on GitHub (May 15, 2026): <!-- linear-linkback --> <p><a href="https://linear.app/netbird/issue/NET-1181">NET-1181</a></p>
Author
Owner

@USBAkimbo commented on GitHub (May 18, 2026):

I have a related issue to this - when logging into the NetBird dashboard using Azure Entra, it always prompts for the admin consent page even after admin consent has been granted

It looks like the prompt=consent is hardcoded in NetBird's Dex connector config in the DB

I've worked around this for now, but it's not ideal and not necessary to show that screen every time

<!-- gh-comment-id:4479348835 --> @USBAkimbo commented on GitHub (May 18, 2026): I have a related issue to this - when logging into the NetBird dashboard using Azure Entra, it always prompts for the admin consent page even after admin consent has been granted It looks like the `prompt=consent` is hardcoded in NetBird's Dex connector config in the DB I've worked around this for now, but it's not ideal and not necessary to show that screen every time
Author
Owner

@jnfrati commented on GitHub (May 19, 2026):

Moving this over to a discussion as per the new issue triage flow https://github.com/netbirdio/netbird/discussions/6074

<!-- gh-comment-id:4486164362 --> @jnfrati commented on GitHub (May 19, 2026): Moving this over to a discussion as per the new issue triage flow https://github.com/netbirdio/netbird/discussions/6074
Sign in to join this conversation.
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: DYNR/netbird#12917