Step-up authentication with OAuth and OpenID Connect

Scott Brady
Scott Brady
OAuth

Implementing step-up authentication doesn’t need to involve your applications orchestrating calls to multiple complex APIs. Instead, by leveraging the features already available to you in open standards, you can build low-friction, stateless step-up authentication for all applications using the protocol libraries they are most likely already using.

In this article, you’re going to learn what step-up authentication is and how you can use step-up authentication in a stateless way within your APIs using OAuth and client applications using OpenID Connect. I’ll even throw in a bonus example that uses SAML 2.0.

You’ll also see an emerging approach where the API (the protected resource) itself can trigger step-up authentication.

What is step-up authentication?

A good example is when a user has authenticated using their username and password, but now they are about to perform a high-risk action. For instance, they could be making a high-value payment or accessing the admin area of your application. At this point, you want to challenge them to prove their identity to a higher level, possibly using a different factor such as TOTP or biometrics. This elevation process is step-up authentication.

A user initially authenticating using a password and being able to view their accounts. Then they try to transfer money but step-up authentication is triggered, asking them to authenticate using multi-factor authentication.

Step-up authentication is useful for protecting high-value actions, but it can also improve your user experience. For example, one concern with some Multi-Factor Authentication (MFA) methods is that they add friction to the user authentication journey. By using step-up authentication, you can allow your users to gain initial access using low-friction authentication methods, only using MFA when necessary. That being said, passwords alone are not enough to protect your user’s accounts, so don’t use this as an excuse.

Step-up authentication in open standards & claims-based identity

The building blocks for step-up authentication are available in open standards such as OAuth and OpenID Connect; you just need to know how to use them. These allow you to implement step-up authentication for API access (OAuth) and application access (OpenID Connect).

Both approaches use standardized claims issued by the authorization server and validated by the recipient of the token (the API or the OAuth client application). These claims could be embedded in the identity token or retrieved using the access token via the introspection endpoint or the token itself.

These claims include:

  • acr – the Authentication Context Reference (ACR) describes the level to which authentication took place. ACR values are agreed between parties ahead of time. For example, the UK’s Open Banking uses an ACR value of urn:openbanking:psd2:sca to state that the user authenticated to the standard of Strong Customer Authentication (SCA).
  • amr – the Authentication Method Reference (AMR) describes how the user authenticated. For example, “pwd” for password, “otp” for a one-time password, or even “mfa” to signal that Multi-Factor Authentication (MFA) took place. See RFC 8176 for example values.
  • auth_time – when the end-user last authenticated at the identity provider. This could be a few hours ago if they have a long-lived single sign-on session.

To learn more about these claims and how the protocols use them, check out “understanding ID tokens”.

Step-up authentication for application access using OpenID Connect

With OpenID Connect, you use identity tokens to understand how the user authenticated themselves at the identity provider. This token lets your application decide if it wants to allow the user to start a session within the app. For example, the app may look at when the user last authenticated (they could be using a long-lived SSO session) and how they authenticated (what methods they used or to what level).

Many applications only check if the ID token is valid and create their own session as a result. But using the identity token, you can start implementing application-level step-up authentication using OpenID Connect.

Initial OpenID Connect authorization request

The client application asks the identity provider for access to the user’s identity and to provide an identity token.

HTTP/1.1 302 Found
  Location: https://idp.example.com/authorize?
    response_type=code
    &scope=openid profile email
    &client_id=s6BhdRkqt3
    &redirect_uri=https://client.example.org/cb

The user will authenticate themselves or use their existing SSO session and consent to sharing their identity. After a bit of back and forth, the client application will receive an identity token. This payload of the identity token will contain some basic claims about how the user authenticated:

{
  "iss": "https://idp.example.com",
  "aud": "s6BhdRkqt3",
  "sub": "5be86359073c434bad2da3932222dabe",
  "auth_time": "1645783823",
  "amr": [ "pwd" ],
  "exp": 1645784123,
  "iat": 1645783823
}

At this point, this may be enough to start a session within the client application. For example, a web application would issue its own cookie.

Initiating step-up authentication using OpenID Connect

However, when accessing a high-risk area of the client application, the current session may not be good enough, and the application will want more assurance that it is the legitimate user. Therefore, it will need the user to authenticate themselves to a higher level using step-up authentication.

Rather than implement authentication methods itself, it can instead ask the identity provider to authenticate the user to a certain level. It can do this using acr_values in the authorization request.

HTTP/1.1 302 Found
  Location: https://idp.example.com/authorize?
    response_type=code
    &scope=openid profile email
    &client_id=s6BhdRkqt3
    &redirect_uri=https://client.example.org/cb
    &acr_values=http://schemas.openid.net/pape/policies/2007/06/multi-factor

This ACR (Authentication Context Reference) value tells the identity provider to authenticate the user to a certain level. For example, the above request uses a value of http://schemas.openid.net/pape/policies/2007/06/multi-factor, telling the identity provider to use MFA. So, if the user’s current session only authenticated using a single-factor (i.e. password), the identity provider will prompt the user to authenticate using their second factor.

Once the user re-authenticates to meet the new level of authentication, a new identity token will be returned to the client application, reflecting this change in session.

{
  "iss": "https://idp.example.com",
  "aud": "s6BhdRkqt3",
  "sub": "5be86359073c434bad2da3932222dabe",
  "auth_time": "1645784467",
  "amr": [ "pwd", "hwk" ],
  "acr": "http://schemas.openid.net/pape/policies/2007/06/multi-factor"
  "exp": 1645784767,
  "iat": 1645784467
}

This new ID token shows that the user:

  1. met a particular ACR level
  2. authenticated themselves using both a password and a FIDO key (hwk = proof-of-possession of a hardware-secured key)
  3. last proved their identity a few seconds ago.

With this new identity token, the client application can see that the user has completed step-up authentication and that it can elevate the session. By validating both the acr claim and the auth time, they can also prove that the step-up authentication took place at their request (i.e. within the last few minutes).

Step-up authentication for API access using OAuth

With OAuth, you can achieve a similar approach where an API endpoint requires a specific level of authentication. You could implement this by requiring a scope that is protected at the authorization server by step-up authentication, or you could follow a similar approach to OpenID Connect by again using the same acr, amr, and auth_time claims, but this time as part of the access token. Let’s look at both approaches.

API step-up authentication using scopes

To protect an API with step-up authentication, you could use an OAuth scope. This scope would not represent step-up authentication, but rather you would enforce that authorizing that scope would require step-up authentication.

For example, if your authorization server receives an authorization request for a particular scope on an API (e.g. a scope that would allow the transfer of money), it would ask the user to reauthenticate or authenticate with a different factor, even if they had already authenticated.

Once a new access token has been issued for that scope, the client application can then access the API. The API would validate that the access token had the correct scope, and maybe it would also check the usual auth_time and acr/amr claims to see if step-up authentication had recently occurred. You could also issue access tokens that are authorized this scope with a shorter lifetime. However, remember that this approach is not transactional; the access token can be re-used.

This approach is not unlike the special treatment OpenID Connect gives the offline_access scope, where consent is always required when requesting refresh tokens.

But this approach requires knowledge of a special scope. Wouldn’t it be better if the API could tell the client application that it needs to trigger step-up authentication? Yes. Yes, it would.

Requesting step-up authentication from an API

With OAuth alongside OpenID Connect, the API can use the access token to understand how the user authenticated themselves at the identity provider. This allows the API to make some authorization decisions: is the access token is valid, and has the user authenticated to a high enough level to call this API endpoint.

Allowing the API itself to request step-up authentication is very powerful. After all, the API understands the level of security required to perform the functionality it holds, and it’s the one who understands if the user can even call this endpoint. Therefore, it’s a perfect place for it to understand and enforce step-up authentication.

This approach uses the same claims and authorization request parameters as the OpenID Connect method but in a way that the API triggers step-up authentication. This approach uses tools already available to you, with error types proposed by Vittorio Bertocci and Brian Campbell at the OAuth Security Workshop 2021. I will use examples where the access token is a JWT, but the same applies to other token formats and the introspection endpoint.

Initial API request

Let’s start after the user has already authenticated, where the client application is trying to call an API that requires the user to use enhanced authentication:

GET /users/8054568ea46e4e6b8e7a30ca34b18f9a HTTP/1.1
  Host: example.com
  Authorization: Bearer eyJ0eXAiOiJhdCtqd3QiLCJhbGciOiJFUzI1NiIsImtpZCI6Ijk3NTExZjU2ZjkyYjhjMGY0YjczMDI4NWEyNDQwMGQzIn0.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlLmNvbSIsImF1ZCI6ImFwaTEiLCJzdWIiOiI1YmU4NjM1OTA3M2M0MzRiYWQyZGEzOTMyMjIyZGFiZSIsImNsaWVudF9pZCI6InM2QmhkUmtxdDMiLCJzY29wZSI6InJlYWQiLCJhdXRoX3RpbWUiOiIxNjQ1NzgzODIzIiwiYW1yIjpbInB3ZCJdLCJleHAiOjE2NDU3ODgxNjEsImlhdCI6MTY0NTc4NDU2MSwianRpIjoiYmEwZjg2NDE4Zjc0N2MyNWU1ODg3N2MwMDlmZmYzZGMifQ.1z4SuiOQHGXojDsAYtt0iCVH8QmYQdJzrdb6EmEg9ZyPeUneO2g_3P0OmyvQ5x2hv0VsPF_QhfmKe8zEVEqwYg

However, this particular API endpoint, not necessarily the scope or API as a whole, requires the user to re-authenticate themselves to an enhanced level. It could even be the particular data or account that they are trying to modify.

While the access token is valid, the user only authenticated using their username and password. Much like with OpenID Connect’s ID token, the access token shows unacceptable AMR values:

{
  "iss": "https://idp.example.com",
  "aud": "api1",
  "sub": "5be86359073c434bad2da3932222dabe",
  "client_id": "s6BhdRkqt3",
  "scope": "read",
  "auth_time": "1645784561",
  "amr": [ "pwd" ],
  "exp": 1645788161,
  "iat": 1645784561,
  "jti": "ba0f86418f747c25e58877c009fff3dc"
}

The API returns a 401 Unauthorized, telling the client application that the token is not good enough. You might expect a 403 Forbidden since the token is valid, but the user cannot complete the request; however, with a 401, you get access to the WWW-Authenticate header.

 HTTP/1.1 401 Unauthorized
  WWW-Authenticate: Bearer realm="example",
                    error="insufficient_authentication_level",
                    error_description="A different level of authentication is required",
                    acr_values="http://schemas.openid.net/pape/policies/2007/06/multi-factor"

Here, the WWW-Authenticate header tells the client application that it needs to trigger step-up authentication and try again with the correct credentials (a new token). insufficient_authentication_level is a proposed error value for signaling step-up authentication, that while the token is valid, the user needs to authenticate better. The acr_values optionally tells the client application what ACR value to request at the identity provider.

As a result, the client application must initiate step-up authentication.

Initiating step-up authentication based on API error

Now that the client understands that step-up authentication is required to complete the API call, it can make the appropriate authorization request to the identity provider. Thanks to the WWW-Authenticate header, it even knows which acr_values to pass across

HTTP/1.1 302 Found
  Location: https://idp.example.com/authorize?
    response_type=code
    &resource=api1
    &scope=read
    &client_id=s6BhdRkqt3
    &redirect_uri=https://client.example.org/cb
    &acr_values=http://schemas.openid.net/pape/policies/2007/06/multi-factor

This results in a new access, with updated claims, showing that step-authentication recently took place.

{
  "iss": "https://idp.example.com",
  "aud": "api1",
  "sub": "5be86359073c434bad2da3932222dabe",
  "client_id": "s6BhdRkqt3",
  "scope": "read",
  "auth_time": "1645785105",
  "amr": [ "pwd", "hwk" ],
  "acr": "http://schemas.openid.net/pape/policies/2007/06/multi-factor"
  "exp": 1645788705,
  "iat": 1645785105,
  "jti": "ba0f86418f747c25e58877c009fff3dc"
}

Just like with the identity token, this new access token shows that the user:

  1. met a particular ACR level
  2. authenticated themselves using both a password and a FIDO key
  3. last proved their identity a few seconds ago.

The client application can now call the API with this new access token, showing the API that the user has completed step-up authentication. By validating both the acr claim and the auth_time, the API can prove that the step-up authentication took place at their request (i.e. within the last few minutes), and therefore the user is authorized to call the API.

The client application can continue using this new access token to call the API until the API decides that the user needs to once again re-authenticate (by checking the auth_time claim).

This approach allows for stateless step-up authentication via open standards, without the need for custom authentication APIs and minting anything prone to error, such as partial tokens.

Step-up authentication for single transactions

If you’re looking for single-use tokens, when the user authorizes a specific request, then this is a different subject for another day and not really part of step-up authentication. The above approach is not transactional.

You could consider having the API record jti (JWT ID) claims to prevent re-use; however, I recommend looking into OAuth’s Rich Authorization Requests (RAR) and the implementation used by UK Open Banking.

Bonus: Step-up authentication with SAML 2.0

You can also trigger step-up authentication with SAML 2.0, again overriding SSO and forcing the user to re-authenticate. Thankfully, the approach is almost identical to the one used with OpenID Connect.

For the service provider (client application/relying party), you check the assertion returned in the SAML response instead of checking the identity token. Let’s take a look at the authentication statement returned in a SAML assertion:

<AuthnStatement xmlns="urn:oasis:names:tc:SAML:2.0:assertion"
  AuthnInstant="2022-02-25T09:24:25Z"
  SessionIndex="_af23066e-7f08-454a-9fe4-425c04d37aec">
  <AuthnContext>
    <AuthnContextClassRef>
      http://schemas.openid.net/pape/policies/2007/06/multi-factor
    </AuthnContextClassRef>
  </AuthnContext>
</AuthnStatement>

This contains an:

  • AuthnInstant, which is when the user authenticated. This attribute corresponds to OpenID Connect’s auth_time claim
  • AuthnContextClassRef, which is how the user authenticated. This attribute corresponds to OpenID Connect’s acr claim.

For then requesting step-up authentication, you can use RequestedAuthnContext element in your SAML AuthnRequest:

<saml2p:AuthnRequest 
  xmlns:saml2p="urn:oasis:names:tc:SAML:2.0:protocol"
  xmlns:saml2="urn:oasis:names:tc:SAML:2.0:assertion"
  <!--Other elements (e.g. issuer)-->
  <saml2p:RequestedAuthnContext Comparison="exact">
    <saml2:AuthnContextClassRef>
      http://schemas.openid.net/pape/policies/2007/06/multi-factor
    </saml2:AuthnContextClassRef>
  </saml2p:RequestedAuthnContext>
</saml2p:AuthnRequest>

This request includes the ACR values that the service provider needs the identity provider to authenticate the user to. A comparison value says that the ACR level can be exact, minimum, better, or maximum; however, the SAML interop profile recommends that you only use exact, which means that the user must meet one of the requested levels.

Again, just like with OAuth and OpenID Connect, both parties must agree on AuthnContextClassRef values ahead of time so that both the service provider and identity provider understand their meaning.

Summary

Step-up authentication doesn’t require complex orchestration with multiple API calls from your applications and reams of documentation. Instead, by leveraging the features already available to you in open standards, you can build a low friction step-up authentication for all applications with the protocol libraries they are most likely already using.

To learn more about the proposed WWW-Authenticate header approach for APIs, check out the initial discussion for the OAuth Security Workshop 2021, which also includes alternatives such as requesting specific AMR values.