Skip to content

security(auth): an MFA_REQUIRED token from a password-only login is accepted on every protected route — mint a PAT or turn MFA off without the second factor #944

Open

Description

@alex-dembele

Problem

A password alone is enough to get past MFA. After a correct password, login returns an MFA_REQUIRED token meant only for POST /auth/mfa/challenge. That token is accepted by every route behind Protected. With a stolen or phished password, an attacker can mint a permanent personal access token with the owner's full permissions, or turn MFA off. The second factor is never asked for.

Cause (confirmed in the code on master 6999958)

  • backend/internal/middleware/auth.go AuthMiddlewareRS256 (behind Protected, protect.go) validates the signature, expiry and blacklist, then sets user_id, tenant_id, roles and permissions from the claims. It never reads claims.Type.
  • backend/pkg/auth/jwt.go ValidateAccessToken doesn't check the type either.
  • pkg/auth defines three types: TokenTypeAccess = "", TokenTypeMFARequired, TokenTypeMFAEnrollment. Only MFATokenMiddleware and MFAEnrollmentMiddleware check them.
  • cmd/server/main.go:995 protected := api.Use(Protected); /auth/mfa/disable (1139) and /auth/pat (1170) sit behind it.

Reproduced live (devsecops review of #714, branch binary on throwaway Postgres + Redis)

  • Root admin with a verified TOTP logs in with the password only and gets {mfa_required: true, mfa_token}.
  • With mfa_token as the bearer: GET /auth/me, /users/me and /auth/sessions return 200. Permission-gated routes return 403, because the token carries no permissions.
  • POST /auth/pat {scopes:["*"]} with mfa_token returns 201. The PAT then reads /risks, /assets and /organization/members with 200, using the owner's permissions. This works for root, who cannot turn MFA off.
  • POST /auth/mfa/disable {password} with mfa_token returns 200. The next login is password-only with a full session. Tested with MFA_REQUIRED_ROLES empty; with the defaults, this applies to every non-privileged member.
  • A unit probe shows the MFA_ENROLLMENT token (15 min) also passes Protected. Only MFAPolicyGuard can still stop it, and only once the grace period is over.

Constraint for the fix

api.Use(Protected) at main.go:995 also runs before /auth/mfa/setup and /auth/mfa/verify, which are registered later on api (1136-1137). Those two routes must keep accepting the MFA_ENROLLMENT token. A blanket refusal of non-access types in Protected is right, but those routes then have to be registered before Protected, as /auth/mfa/challenge already is (912). Otherwise the enrolment of a role that requires MFA breaks.

Acceptance criteria

  1. Given an MFA_REQUIRED token, every route behind Protected answers 401 TOKEN_INVALID. This includes /auth/me, /auth/pat (create, list, revoke), /auth/mfa/disable and /auth/sessions.
  2. Given an MFA_ENROLLMENT token, every route behind Protected answers 401, except POST /auth/mfa/setup and POST /auth/mfa/verify, which keep working. A role that requires MFA can still enrol from the sign-in screen.
  3. Given a full access token, cookie or bearer, and given a PAT, behaviour is unchanged.
  4. POST /auth/mfa/challenge still accepts only MFA_REQUIRED.
  5. A ratchet test sends each token type to Protected, /auth/pat, /auth/mfa/disable, /auth/mfa/setup and /auth/mfa/verify, and fails if a new route behind Protected accepts a typed token.
  6. A PAT minted before the fix by an MFA_REQUIRED token cannot be identified by type afterwards. The PR says how operators can find and revoke such tokens, for example PATs created within minutes of an mfa_required login with no successful challenge, or says why that isn't possible.

Definition of Done

  • TestProtected_Success (access token), TestProtected_NotFound (unknown or revoked jti), TestProtected_Unauthorized (both typed tokens), plus the route ratchet of criterion 5
  • go test ./... with output pasted; route/authz/isolation registry tests green
  • Live: password-only login → mfa_token → /auth/pat 401, /auth/mfa/disable 401; enrolment of a role that requires MFA still succeeds end to end
  • Security review (devsecops) of the fix
  • Claim matrix: check any MFA claim; MFA could be bypassed until this ships

Found by the devsecops review of #714 (PR #898). Not caused by #714.

Activity

  1. added this to the trust-v1 milestone on Oct 10, 2026
  2. added
    type:securitySecurity defect or hardening
    priority:P0-criticalProduction broken or exposed — work now
    status:readyMeets the ready definition
    tier:0-trustTrust: security, isolation, evidence integrity
    on Oct 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area:backendGo, /internal, /pkgpriority:P0-criticalProduction broken or exposed — work nowstatus:readyMeets the ready definitiontier:0-trustTrust: security, isolation, evidence integritytype:securitySecurity defect or hardening

    Type

    No type

    Projects

    No projects

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions