# Nhost Vulnerable to Account Takeover via OAuth Email Verification Bypass

Nhost OAuth adapters (Discord, Bitbucket, AzureAD, EntraID) mis-set EmailVerified, letting an attacker link an unverified email to a victim's account and take it over.

- Payout: Research
- Severity: critical
- Program: Nhost
- Found by: skoveit
- Disclosed: 2026-04-18
- CVE: CVE-2026-41574
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-6g38-8j4p-j3pr
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-6g38-8j4p-j3pr
- Tags: area:web domain:web impact:ato kind:research platform:ghsa sev:critical tech:go tech:oauth tech:oidc technique:source-review vuln:access-control vuln:account-takeover vuln:auth-bypass

**Target:** OAuth sign-in flow / provider adapters in Nhost auth service
**Root cause:** providerFlowSignIn links a new provider identity to an existing account by email match without checking profile.EmailVerified, and several provider adapters set EmailVerified to `email != ""` instead of the provider's real verified/email_verified claim.
**Steps:**
1. Attacker changes Discord account email to victim's address (`admin@target.io`) and dismisses the confirmation dialog, leaving `"verified": false`.
2. Attacker clicks 'Sign in with Discord' on the target Nhost app.
3. Nhost's discordUserProfile struct lacks the `verified` field, so JSON decoding drops it; adapter sets `EmailVerified: userProfile.Email != ""` → true.
4. providerFlowSignIn finds victim's account by email, sees no Discord provider row, calls `InsertUserProvider` linking attacker's Discord ID.
5. Nhost issues a full session for the victim's account to the attacker.
**How found:** Code review of each provider adapter against provider API docs: noticed Discord's User Object documents a `verified` boolean absent from Nhost's struct, Bitbucket's fallback stores unconfirmed emails, and AzureAD/EntraID derive email from preferred_username/UPN and ignore `email_verified`, then traced trust to providerFlowSignIn's unguarded email-match linking.
**Exploitation:** No victim interaction: attacker registers/uses a Discord (or Bitbucket/AzureAD/EntraID) account, sets its email to the victim's without confirming, then performs OAuth sign-in. Nhost merges the identity into the victim's account and returns an authenticated session, allowing email change and lockout of the legitimate owner.
**Escalation:** Critical in apps with admin/privileged accounts; attacker can change account email and disable other login methods to permanently lock out the owner.
**Insight:** When auditing OAuth account-linking, diff each provider adapter's struct against the provider's documented user object for missing `verified`/`email_verified` fields, and flag any `EmailVerified: email != ""` shortcut plus fallbacks to unconfirmed emails or non-ownership fields like UPN/preferred_username.

## Proof of concept (copied exactly from the original report)

**Discord adapter struct missing verified field**

```
type discordUserProfile struct {
    ID            string `json:"id"`
    Username      string `json:"username"`
    Discriminator string `json:"discriminator"`
    Email         string `json:"email"`
    Locale        string `json:"locale"`
    Avatar        string `json:"avatar"`
    // MISSING: Verified bool `json:"verified"`
}

The adapter then sets:

EmailVerified: userProfile.Email != "",  // always true when email is present
```

**Bitbucket unconfirmed email fallback**

```
for _, e := range emailResp.Values {
    if e.IsConfirmed {
        primaryEmail = e.Email
        break
    } else if fallbackEmail == "" {
        fallbackEmail = e.Email     // stores unconfirmed email
    }
}

if primaryEmail == "" {
    if fallbackEmail == "" {
        return oidc.Profile{}, ErrNoConfirmedBitbucketEmail
    }
    primaryEmail = fallbackEmail    //  uses unconfirmed email
}

return oidc.Profile{
    Email:         primaryEmail,
    EmailVerified: primaryEmail != "",  //  marks it true anyway
```

**AzureAD UPN fallback marked verified**

```
email := userProfile.Email
if email == "" {
    email = userProfile.Prefer   // "preferred_username"  not an email ownership proof
}
if email == "" {
    email = userProfile.UPN      // User Principal Name  not an email ownership proof
}

return oidc.Profile{
    Email:         email,
    EmailVerified: email != "",  // marked verified regardless of source
```

