apereo/cas · error · FailedLoginException

Authorization of OTP token

Error message

Authorization of OTP token [{}] has failed for [{}]

What it means

In GoogleAuthenticatorValidateTokenAction the OTP token passed validation but fails the authorization check that ties the validated token to the authenticated principal's registered GAuth account. CAS treats this as a failed login: a FailedLoginException is thrown with the message "Failed to authenticate code <token>". This indicates the token was syntactically valid but did not correspond to an account owned by the given principal.

Solutions

  1. Confirm the OTP was generated from the device registered to the authenticated principal
  2. Check the GAuth account registry (JSON/JDBC/Mongo) for the principal's account and verify its secret and user id fields are correct
  3. Re-register the device for the user if the stored secret does not match the authenticator app
  4. Review any custom OneTimeTokenValidator/account-lookup beans for principal-mapping errors

Example fix

// before (account bound to wrong user)
{"id": 1, "username": "otheruser", "secret": "..."}
// after
{"id": 1, "username": "correctuser", "secret": "..."}
Defensive patterns

Strategy: try-catch

Validate before calling

if (validatedToken == null || !principal.getId().equals(validatedToken.getPrincipalId())) {
    throw new FailedLoginException("OTP token not owned by current principal");
}

Try / catch

try {
    authCtx = gauthValidateAction.execute(ctx);
} catch (FailedLoginException e) {
    LOGGER.warn("GAuth authorization failed: {}", e.getMessage());
    return flowError(e);
}

Prevention

When it happens

Trigger: A one-time token is presented whose computed value matches but whose owning account is not linked to the current authentication's principal (wrong account binding, principal mismatch, or account registry lookup returning a registration for another user).

Common situations: Users switching accounts mid-session; tokens generated against a different registration; missing or corrupted GAuth account records in the registry after import/restore; custom token validators that skip account-ownership checks.

Related errors


AI-assisted analysis of apereo/cas@e7288fc434 (2026-09-08). Data as JSON: /api/errors/7316de92d7912138. Report an issue: GitHub.

Appendix: source

Thrown at support/cas-server-support-gauth-core/src/main/java/org/apereo/cas/gauth/web/flow/GoogleAuthenticatorValidateTokenAction.java:52

    @Override
    protected @Nullable Event doExecuteInternal(final RequestContext requestContext) throws Throwable {
        val token = requestContext.getRequestParameters().getRequired(GoogleAuthenticatorSaveRegistrationAction.REQUEST_PARAMETER_TOKEN, String.class);
        val accountId = requestContext.getRequestParameters().getRequired(OneTimeTokenAccountConfirmSelectionRegistrationAction.REQUEST_PARAMETER_ACCOUNT_ID, Long.class);

        val authentication = WebUtils.getAuthentication(requestContext);
        val tokenCredential = new GoogleAuthenticatorTokenCredential(token, accountId);
        val validatedToken = validator.validate(authentication, tokenCredential);
        if (validatedToken != null) {
            val principal = authentication.getPrincipal().getId();
            LOGGER.debug("Validated OTP token [{}] successfully for [{}]", validatedToken, principal);
            val validate = requestContext.getRequestParameters().getBoolean(OneTimeTokenAccountSaveRegistrationAction.REQUEST_PARAMETER_VALIDATE);
            if (validate == null || !validate) {
                validator.store(validatedToken);
            }
            return success();
        }
        LOGGER.warn("Authorization of OTP token [{}] has failed for [{}]", token, authentication.getPrincipal().getId());
        throw new FailedLoginException("Failed to authenticate code " + token);
    }
}

View on GitHub (pinned to e7288fc434)