Force Reauthentication in OIDC

The prompt=login mechanism can be subverted by simply stripping the parameter as it passes through the user agent (browser) and is only good for providing a UX hint to the OpenID provider (OP) in cases when the relying party (RP) wants to display a link like:

“Hi Josh. Not you? Click here.”

However, you should not rely on it to validate that a fresh authentication took place. To mitigate this, the client must validate that re-authentication has taken place using the auth_time claim if re-authentication is why max_age was requested. This claim will be included automatically in the ID token when prompt-login or max_age=0 parameters are given in the authentication request.

You need to pass the max_age parameter to the Authorization API /authorize endpoint. If you use Auth0.js or Lock, you can set the parameter in the appropriate options of the library.

How you implement re-authentication depends on your specific use-case. Make a distinction between simple re-authentication for sensitive operations vs. step-up (i.e. multi-factor authentication) for sensitive operations. Both are valid security measures. The former requires the end user to re-enter their password, whereas the latter requires them to use a pre-configured means of multifactor authentication as well.

Limitations of prompt=login parameters

The OIDC spec defines the prompt=login parameter that can be used to trigger re-authentication UI (usually a login prompt):

However, there is an issue with using this parameter to ensure re-authentication: the RP has no way to validate that a re-authentication action has taken place. Let's inspect the traffic to understand why. The flow for an authentication request from the RP is as follows:

https://mydomain.auth0.com/authorize?
client_id=abcd1234
&redirect_uri= https://mydomain.com/callback

&scope=openid profile
&response_type=id_token
&prompt=login

Was this helpful?

/

Upon successful authentication by the AS, the RP will have an ID token delivered:

{
  "nickname": "user",
  "name": "user@mydomain.auth0.com",
  "updated_at": "2019-04-01T14:43:03.445Z",
  "iss": "https://jcain0.auth0.com/",
  "sub": "auth0|l33t",
  "aud": "abcd1234",
  "iat": 1554129793,
  "exp": 1554165793
}

Was this helpful?

/

The trusted identity document returned by the AS has no claims that validate when the last login occurred. This becomes a problem when the initial authorization request comes in the form of a 302 redirect through the end user’s browser. If a malicious actor wants to skip the re-authentication step requested by the RP, they simply have to remove the prompt=login parameter and the RP doesn't know the difference in the fields contained in the ID token.

Here’s a diagram of a simplified implicit flow using the prompt=login parameter:

Force Re-Authentication OIDC Implicit Flow

Note that all the end-user has to do is remove the prompt=login parameter and the re-authentication step can be skipped:

Simplified Implicit Flow Remove prompt=login

The token(s) returned from the first flow above will be identical to the token(s) returned from the second flow. The RP has no specification-defined way of verifying that re-authentication has taken place, and therefore cannot trust that a prompt=login has actually yielded a re-authentication.

max_age authentication request parameter

Unlike prompt=login, the max_age authentication request parameter provides a mechanism whereby RPs can positively confirm that re-authentication has taken place within a given time interval. The OIDC spec states:

The last sentence in the definition is the most important part. When max_age is requested by the RP, an auth_time claim must be present in the RP. This means that max_age can be used in one of two ways:

  • To enforce a minimum session freshness: If an app has a requirement that users must re-authenticate once per day, this can be enforced in the context of a much longer SSO session by providing max_age with a value. These are defined in seconds.

  • To force an immediate re-authentication: If an app requires that a user re-authenticate prior to access, provide a value of 0 for the max_age parameter and the AS will force a fresh login.

This requirement is described as follows:

OIDC re-authentication max_age flow

Note that the RP receives a token with the proper amount of information to validate whether or not re-authentication has taken place. The RP can now consult the auth_time claim in the ID token to determine whether or not the max_age parameter it requested was fulfilled. In this way, the max_age=0 parameter is impervious to the same kind of client tampering that could subvert the prompt=login parameter.

Use auth_time claims

We've established that the OIDC spec provides the max_age parameter as a way to positively confirm a re-authentication has taken place, but prompt=login does not. This does not present very secure options if you want to force a re-authentication:

  • prompt=login: Only include the prompt parameter and not validate that the AS actually re-authenticated.

  • prompt=login & max_age=999999: Include an arbitrary max_age such that an auth_time claim is present. You can validate a re-authentication took place, but the parameters get messy.

  • max_age=0: Effectively force a login prompt using only the max_age parameter. Note that a recent spec update further clarified this parameter, stating it is effectively the same as prompt=login. This one is not feasible since it blends what should be a UX parameter with a session maintenance parameter.

Instead, Auth0 has made a choice to send the auth_time claim in the ID token when responding to a prompt=login request parameter. This means that you have the option use prompt=login AND validate that a re-authentication took place.

auth_time validation example

The following example uses the passport-auth0-openidconnect module to demonstrate how to validate re-authentication. The first (and simplest) way is to add the max_age=0 option to the Auth0OidcStrategy:

var strategy = new Auth0OidcStrategy(
  {
    domain: process.env.AUTH0_DOMAIN,
    clientID: process.env.AUTH0_CLIENT_ID,
    clientSecret: process.env.AUTH0_CLIENT_SECRET,
    callbackURL: process.env.AUTH0_CALLBACK_URL || 'http://localhost:5000/callback',
    max_age: 0
  },
  function(req, issuer, audience, profile, accessToken, refreshToken, params, cb) {
    // No extra validation required!
    return cb(null, profile);
  });

Was this helpful?

/

Notice that no further validation steps are required as the strategy already handles validation of the max_age parameter:

// https://openid.net/specs/openid-connect-basic-1_0.html#IDTokenValidation - check 8.
if (meta.params.max_age && (!jwtClaims.auth_time || ((meta.timestamp - meta.params.max_age) > jwtClaims.auth_time))) {
  return self.error(new Error('auth_time in id_token not included or too old'));
}

Was this helpful?

/

You can also use prompt=login in the same context, but since the standard does not require an auth_time to accompany the ID token response, you must handle the validation manually. So, the strategy constructor would be:

var strategy = new Auth0OidcStrategy(
  {
    domain: process.env.AUTH0_DOMAIN,
    clientID: process.env.AUTH0_CLIENT_ID,
    clientSecret: process.env.AUTH0_CLIENT_SECRET,
    callbackURL: process.env.AUTH0_CALLBACK_URL || 'http://localhost:5000/callback',
    prompt: 'login'
  },
  function(req, issuer, audience, profile, accessToken, refreshToken, params, cb) {
    const tenSecondsAgo = (Date.now() / 1000) - 10;
    if (isNaN(profile.auth_time) || profile.auth_time < tenSecondsAgo) {
      return cb('prompt=login requested, but auth_time is greater than 10 seconds old', null);
    }

    return cb(null, profile);
  });

Was this helpful?

/

Unlike max_age=0, the client must manually perform validation on the auth_time parameter. To learn more, read Use auth_time claims.

Known issues

Auth0 can only guarantee that an exchange took place with the upstream identity provider. This may be through the user actually signing in to a third-party identity provider or perhaps the user already had a session and didn't have to sign in again. Either way, Auth0’s exchange with the upstream identity provider will result in an updated auth_time.

Forcing re-authentication within the upstream identity provider is not something Auth0 supports because not all providers support this.

The diagram below presents an example flow for a user who chooses to reauthenticate with a federated connection:

Federated connections do not force re-authentication diagram

This method assumes you use database connections. External identity providers may or may not support forcing re-authentication. Using prompt=login or prompt=consent is generally a way to indicate an external (social) identity provider to reauthenticate a user, but Auth0 cannot enforce this.

Learn more