Zitadel Bypass Second Authentication Factor
Description
Starting from 2.53.6, 2.54.3, and 2.55.0, Zitadel only required multi factor authentication in case the login policy has either enabled requireMFA or requireMFAForLocalUsers. If a user has set up MFA without this requirement, Zitadel would consider single factor auhtenticated sessions as valid as well and not require multiple factors. Bypassing second authentication factors weakens multifactor authentication and enables attackers to bypass the more secure factor. An attacker can target the TOTP code alone, only six digits, bypassing password verification entirely and potentially compromising accounts with 2FA enabled. This vulnerability is fixed in 4.6.0, 3.4.3, and 2.71.18.
Affected packages
Versions sourced from the GitHub Security Advisory.
| Package | Affected versions | Patched versions |
|---|---|---|
github.com/zitadel/zitadel/v2Go | >= 2.53.6, <= 2.53.9 | — |
github.com/zitadel/zitadel/v2Go | >= 2.54.3, <= 2.54.10 | — |
github.com/zitadel/zitadel/v2Go | >= 2.55.0, < 2.71.18 | 2.71.18 |
github.com/zitadel/zitadelGo | < 1.80.0-v2.20.0.20251029091250-b284f8474eed | 1.80.0-v2.20.0.20251029091250-b284f8474eed |
Affected products
1Patches
1b284f8474eedMerge commit from fork
6 files changed · +122 −19
internal/api/grpc/session/v2/integration_test/session_test.go+11 −0 modified@@ -970,6 +970,17 @@ func Test_ZITADEL_API_missing_authentication(t *testing.T) { }, retryDuration, tick) } +func Test_ZITADEL_API_missing_mfa(t *testing.T) { + mfaUser := createFullUser(CTX) + registerTOTP(CTX, t, mfaUser.GetUserId()) + id, token, _, _ := Instance.CreatePasswordSession(t, LoginCTX, mfaUser.GetUserId(), integration.UserPassword) + ctx := integration.WithAuthorizationToken(context.Background(), token) + + sessionResp, err := Instance.Client.SessionV2.GetSession(ctx, &session.GetSessionRequest{SessionId: id}) + require.Error(t, err) + require.Nil(t, sessionResp) +} + func Test_ZITADEL_API_success(t *testing.T) { id, token, _, _ := Instance.CreateVerifiedWebAuthNSession(t, LoginCTX, User.GetUserId()) ctx := integration.WithAuthorizationToken(context.Background(), token)
internal/api/oidc/integration_test/oidc_test.go+32 −0 modified@@ -120,6 +120,38 @@ func Test_ZITADEL_API_missing_authentication(t *testing.T) { require.Nil(t, myUserResp) } +func Test_ZITADEL_API_missing_mfa_2fa_setup(t *testing.T) { + clientID, _ := createClient(t, Instance) + org := Instance.CreateOrganization(CTXIAM, integration.OrganizationName(), integration.Email()) + userID := org.CreatedAdmins[0].GetUserId() + Instance.SetUserPassword(CTXIAM, userID, integration.UserPassword, false) + Instance.RegisterUserU2F(CTXIAM, userID) + authRequestID := createAuthRequest(t, Instance, clientID, redirectURI, oidc.ScopeOpenID, zitadelAudienceScope) + sessionID, sessionToken, startTime, changeTime := Instance.CreatePasswordSession(t, CTXLOGIN, userID, integration.UserPassword) + linkResp, err := Instance.Client.OIDCv2.CreateCallback(CTXLOGIN, &oidc_pb.CreateCallbackRequest{ + AuthRequestId: authRequestID, + CallbackKind: &oidc_pb.CreateCallbackRequest_Session{ + Session: &oidc_pb.Session{ + SessionId: sessionID, + SessionToken: sessionToken, + }, + }, + }) + require.NoError(t, err) + + // code exchange + code := assertCodeResponse(t, linkResp.GetCallbackUrl()) + tokens, err := exchangeTokens(t, Instance, clientID, code, redirectURI) + require.NoError(t, err) + assertIDTokenClaims(t, tokens.IDTokenClaims, userID, armPassword, startTime, changeTime, sessionID) + + ctx := metadata.AppendToOutgoingContext(context.Background(), "Authorization", fmt.Sprintf("%s %s", tokens.TokenType, tokens.AccessToken)) + + myUserResp, err := Instance.Client.Auth.GetMyUser(ctx, &auth.GetMyUserRequest{}) + require.Error(t, err) + require.Nil(t, myUserResp) +} + func Test_ZITADEL_API_missing_mfa_policy(t *testing.T) { clientID, _ := createClient(t, Instance) org := Instance.CreateOrganization(CTXIAM, integration.OrganizationName(), integration.Email())
internal/authz/repository/eventsourcing/eventstore/token_verifier.go+26 −5 modified@@ -4,6 +4,7 @@ import ( "context" "encoding/base64" "fmt" + "slices" "strings" "time" @@ -177,26 +178,46 @@ func (repo *TokenVerifierRepo) checkAuthentication(ctx context.Context, authMeth if len(authMethods) == 0 { return zerrors.ThrowPermissionDenied(nil, "AUTHZ-Kl3p0", "authentication required") } + // if the user has MFA, we don't need to check any mfa requirements if domain.HasMFA(authMethods) { return nil } requirements, err := repo.Query.ListUserAuthMethodTypesRequired(setCallerCtx(ctx, userID), userID) if err != nil { return err } + // machine users do not have interactive logins, so we don't check for MFA requirements if requirements.UserType == domain.UserTypeMachine { return nil } - if domain.RequiresMFA( - requirements.ForceMFA, - requirements.ForceMFALocalOnly, - !hasIDPAuthentication(authMethods), - ) { + // we'll only require 2FA factors, that are allowed by the policy + allowedFactors := allowed2FAFactors(requirements.AllowedSecondFactors, requirements.SetUpFactors) + // if either the user has set up a factor that is allowed by the policy + // or the policy requires MFA, we'll require it and can directly return the error + // since the token/session was not authenticated with MFA + if domain.Has2FA(allowedFactors) || + domain.RequiresMFA( + requirements.ForceMFA, + requirements.ForceMFALocalOnly, + !hasIDPAuthentication(authMethods), + ) { return zerrors.ThrowPermissionDenied(nil, "AUTHZ-Kl3p0", "mfa required") } return nil } +func allowed2FAFactors(factors []domain.SecondFactorType, authMethods []domain.UserAuthMethodType) []domain.UserAuthMethodType { + allowedFactors := make([]domain.UserAuthMethodType, 0, len(factors)) + for _, method := range authMethods { + factorType := domain.AuthMethodToSecondFactor(method) + if factorType != domain.SecondFactorTypeUnspecified && + slices.Contains(factors, factorType) { + allowedFactors = append(allowedFactors, method) + } + } + return allowedFactors +} + func hasIDPAuthentication(authMethods []domain.UserAuthMethodType) bool { for _, method := range authMethods { if method == domain.UserAuthMethodTypeIDP {
internal/domain/user.go+19 −0 modified@@ -107,6 +107,25 @@ func RequiresMFA(forceMFA, forceMFALocalOnly, isInternalLogin bool) bool { return forceMFA && !forceMFALocalOnly } +// AuthMethodToSecondFactor maps user auth methods to their corresponding second factor types +func AuthMethodToSecondFactor(method UserAuthMethodType) SecondFactorType { + switch method { + case UserAuthMethodTypeTOTP: + return SecondFactorTypeTOTP + case UserAuthMethodTypeU2F: + return SecondFactorTypeU2F + case UserAuthMethodTypeOTPSMS: + return SecondFactorTypeOTPSMS + case UserAuthMethodTypeOTPEmail: + return SecondFactorTypeOTPEmail + case UserAuthMethodTypeOTP: + return SecondFactorTypeOTPSMS + default: + // First-factor methods: password, IDP, passwordless, private key + return 0 + } +} + type PersonalAccessTokenState int32 const (
internal/query/user_auth_method.go+15 −6 modified@@ -12,6 +12,7 @@ import ( "github.com/zitadel/logging" "github.com/zitadel/zitadel/internal/api/authz" + "github.com/zitadel/zitadel/internal/database" "github.com/zitadel/zitadel/internal/domain" "github.com/zitadel/zitadel/internal/query/projection" "github.com/zitadel/zitadel/internal/telemetry/tracing" @@ -222,9 +223,11 @@ func (q *Queries) ListUserAuthMethodTypes(ctx context.Context, userID string, ac } type UserAuthMethodRequirements struct { - UserType domain.UserType - ForceMFA bool - ForceMFALocalOnly bool + UserType domain.UserType + ForceMFA bool + ForceMFALocalOnly bool + AllowedSecondFactors []domain.SecondFactorType + SetUpFactors []domain.UserAuthMethodType } //go:embed user_auth_method_types_required.sql @@ -245,10 +248,14 @@ func (q *Queries) ListUserAuthMethodTypesRequired(ctx context.Context, userID st var userType sql.NullInt32 var forceMFA sql.NullBool var forceMFALocalOnly sql.NullBool + var allowedSecondFactors database.NumberArray[domain.SecondFactorType] + var setUpFactors database.NumberArray[domain.UserAuthMethodType] err := row.Scan( &userType, &forceMFA, &forceMFALocalOnly, + &allowedSecondFactors, + &setUpFactors, ) if err != nil { if errors.Is(err, sql.ErrNoRows) { @@ -257,9 +264,11 @@ func (q *Queries) ListUserAuthMethodTypesRequired(ctx context.Context, userID st return zerrors.ThrowInternal(err, "QUERY-Sf3rt", "Errors.Internal") } requirements = &UserAuthMethodRequirements{ - UserType: domain.UserType(userType.Int32), - ForceMFA: forceMFA.Bool, - ForceMFALocalOnly: forceMFALocalOnly.Bool, + UserType: domain.UserType(userType.Int32), + ForceMFA: forceMFA.Bool, + ForceMFALocalOnly: forceMFALocalOnly.Bool, + AllowedSecondFactors: allowedSecondFactors, + SetUpFactors: setUpFactors, } return nil },
internal/query/user_auth_method_types_required.sql+19 −8 modified@@ -1,17 +1,28 @@ -SELECT +SELECT projections.users14.type , auth_methods_force_mfa.force_mfa - , auth_methods_force_mfa.force_mfa_local_only -FROM - projections.users14 -LEFT JOIN + , auth_methods_force_mfa.force_mfa_local_only + , auth_methods_force_mfa.second_factors + , user_auth_methods5.auth_method_types +FROM + projections.users14 +LEFT JOIN projections.login_policies5 AS auth_methods_force_mfa ON auth_methods_force_mfa.instance_id = projections.users14.instance_id AND auth_methods_force_mfa.aggregate_id = ANY(ARRAY[projections.users14.instance_id, projections.users14.resource_owner]) -WHERE +LEFT JOIN LATERAL ( + SELECT + ARRAY_AGG(projections.user_auth_methods5.method_type) AS auth_method_types + FROM + projections.user_auth_methods5 + WHERE + projections.user_auth_methods5.user_id = projections.users14.id + AND projections.user_auth_methods5.instance_id = projections.users14.instance_id + ) AS user_auth_methods5 ON TRUE +WHERE projections.users14.id = $1 AND projections.users14.instance_id = $2 -ORDER BY - auth_methods_force_mfa.is_default +ORDER BY + auth_methods_force_mfa.is_default LIMIT 1; \ No newline at end of file
Vulnerability mechanics
Generated by null/stub on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.
References
5- github.com/advisories/GHSA-cfjq-28r2-4jv5ghsaADVISORY
- nvd.nist.gov/vuln/detail/CVE-2025-64103ghsaADVISORY
- github.com/zitadel/zitadel/commit/b284f8474eed0cba531905101619e7ae7963156bghsax_refsource_MISCWEB
- github.com/zitadel/zitadel/security/advisories/GHSA-cfjq-28r2-4jv5ghsax_refsource_CONFIRMWEB
- pkg.go.dev/vuln/GO-2025-4083ghsaWEB
News mentions
0No linked articles in our index yet.