Require email_verified and make first-admin bootstrap run once #60

Closed
opened 2026-07-28 05:57:11 +00:00 by claude-bot · 1 comment

Severity: HIGH

The bug

backend/app/api/routes/auth.py:49,60 trusts the email claim without checking
email_verified — the string appears nowhere in the codebase — and uses it to grant admin:

email: str = (userinfo.get("email") or "").lower()
...
if email in settings.first_admin_email_set and user.role != UserRole.admin:
    user.role = UserRole.admin

Two problems:

  1. Unverified email grants admin. Against an IdP that permits unverified addresses, an
    attacker registers claiming the address in CIRCA_FIRST_ADMIN_EMAILS and is promoted on
    first login. Email is also a unique column, so an unverified address can squat an identity.
  2. The bootstrap never closes. Despite the name, the check runs on every login, not only
    the first. An attacker who gains control of a matching address at any point in the future
    gets admin retroactively.

Authentik can be configured to require verification, so this may not be currently exploitable —
but the application should not depend on an external system's configuration for a privilege
grant this significant.

Fix

  • Require userinfo.get("email_verified") is True before the email is used for anything —
    bootstrap, identity matching, or storage. Reject the login with a clear message otherwise.
  • Make the bootstrap genuinely first-run-only: apply it only when the user row is newly created,
    or only while zero admins exist in the database.

Done when

  • Logins with an unverified email are rejected
  • First-admin promotion happens at most once, and cannot re-fire on later logins
  • Tests cover both the unverified rejection and the bootstrap-closes behaviour

References

  • backend/app/api/routes/auth.py:49,60
  • backend/app/config.py (first_admin_email_set)
## Severity: HIGH ## The bug `backend/app/api/routes/auth.py:49,60` trusts the `email` claim without checking `email_verified` — the string appears nowhere in the codebase — and uses it to grant **admin**: ```python email: str = (userinfo.get("email") or "").lower() ... if email in settings.first_admin_email_set and user.role != UserRole.admin: user.role = UserRole.admin ``` Two problems: 1. **Unverified email grants admin.** Against an IdP that permits unverified addresses, an attacker registers claiming the address in `CIRCA_FIRST_ADMIN_EMAILS` and is promoted on first login. Email is also a `unique` column, so an unverified address can squat an identity. 2. **The bootstrap never closes.** Despite the name, the check runs on *every* login, not only the first. An attacker who gains control of a matching address at any point in the future gets admin retroactively. Authentik can be configured to require verification, so this may not be currently exploitable — but the application should not depend on an external system's configuration for a privilege grant this significant. ## Fix - Require `userinfo.get("email_verified") is True` before the email is used for anything — bootstrap, identity matching, or storage. Reject the login with a clear message otherwise. - Make the bootstrap genuinely first-run-only: apply it only when the user row is newly created, or only while zero admins exist in the database. ## Done when - [ ] Logins with an unverified email are rejected - [ ] First-admin promotion happens at most once, and cannot re-fire on later logins - [ ] Tests cover both the unverified rejection and the bootstrap-closes behaviour ## References - `backend/app/api/routes/auth.py:49,60` - `backend/app/config.py` (`first_admin_email_set`)
claude-bot added this to the v0.1.1 milestone 2026-07-28 05:57:11 +00:00
Author

Fixed in 4333b5e. CI green.

Two changes.

email_verified is now required. The email claim drives both the allowlist (#56) and the admin
bootstrap, so it is rejected with 403 unless the provider marks it verified. Authentik can be
configured to require verification, but the application should not depend on an external system's
configuration for a privilege grant this significant.

The bootstrap window now closes. It only ever applied "on first login" in name — the check ran on
every login, so anyone who later gained control of a listed address was promoted retroactively,
and an admin demoted through the UI would be silently re-promoted next time they signed in. It now
requires both a newly created account and not admin_exists().

One deliberate subtlety: admin_exists() filters on is_active, so a deactivated sole admin
reopens the window. Otherwise deactivating the only admin would permanently lock the deployment
out of its own administration with no recovery path short of editing the database.

**Fixed** in 4333b5e. CI green. Two changes. **`email_verified` is now required.** The email claim drives both the allowlist (#56) and the admin bootstrap, so it is rejected with 403 unless the provider marks it verified. Authentik can be configured to require verification, but the application should not depend on an external system's configuration for a privilege grant this significant. **The bootstrap window now closes.** It only ever applied "on first login" in name — the check ran on *every* login, so anyone who later gained control of a listed address was promoted retroactively, and an admin demoted through the UI would be silently re-promoted next time they signed in. It now requires both a newly created account and `not admin_exists()`. One deliberate subtlety: `admin_exists()` filters on `is_active`, so a deactivated sole admin **reopens** the window. Otherwise deactivating the only admin would permanently lock the deployment out of its own administration with no recovery path short of editing the database.
Sign in to join this conversation.
No description provided.