Understanding identity tokens

Scott Brady
Scott Brady
OpenID Connect

OpenID Connect builds upon OAuth 2 with a new token type: the ID token (identity token). This identity token allows client applications to start understanding users and authentication, which isn’t possible with OAuth alone. However, with a new token type comes something new to learn, a new set of misunderstandings, and new ways for things to go wrong.

In this article, you will learn what identity tokens are, where to use them, and how to validate them, dispelling some common myths along the way. You’ll also see some advanced scenarios where identity tokens can help you with elevation scenarios such as step-up authentication.

What is an identity token?

The ID Token is a security token that contains Claims about the Authentication of an End-User by an Authorization Server when using a Client, and potentially other requested Claims.

I prefer a simpler definition: the identity token describes the authentication event.

An identity token describes how & when the user authenticated at the authorization server/identity provider, providing enough data for a client application to decide if it wants to create its own session for the user.

While an identity token can also contain identity data about the end-user, this is not guaranteed. Surprisingly, the identity part of the identity token is optional.

In the OpenID Connect specification, you’ll always see them referred to as ID tokens; however, it’s also common to see them called identity tokens. Either identity token or ID token is fine though.

How to get an identity token

To get an identity token, you need to ask for the openid scope in your authorization request. This scope tells the identity provider that you want to use OpenID Connect and find out how the user authenticated.

https://idp.local/authorize
  ?client_id=my_client_app
  &redirect_uri=https://client.local/callback
  &response_type=code
  &scope=openid
  &nonce=xyz
  &state=123

If the request succeeds, the identity provider will return an identity token in the token response using the id_token parameter (after the code swap).

{
    "access_token": "f90aac6a954b0b39d403c8cc2077cec054f6e26af8077bec1fbc97bd641b6f90",
    "token_type": "Bearer",
    "expires_in": 3600,
    "id_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6IjFlOWdkazcifQ.eyJpc3MiOiJodHRwczovL2lkcC5sb2NhbCIsImF1ZCI6Im15X2NsaWVudF9hcHAiLCJzdWIiOiI1YmU4NjM1OTA3M2M0MzRiYWQyZGEzOTMyMjIyZGFiZSIsImV4cCI6MTYxNTExNTI3NywiaWF0IjoxNjE1MTE0OTc3LCJuYmYiOjE2MTUxMTQ5NzcsImF1dGhfdGltZSI6MTYxNTExNDk3MCwiYW1yIjpbInB3ZCJdLCJub25jZSI6IlBkeGFYVGdfVEhzeW5hOXdmWXBzRC1fcVZwV2pseWpqY2w4NjRLMHdieFkiLCJhdF9oYXNoIjoiMkt4bUwyN0NNOVZDNWVMNldrUzF1dyIsInNpZCI6ImRlNzFjNTgyZDgyZDI4NmI1YzYwMDdkMzNkYTgxODRiIn0.wk-85Ii0shImXdnrrVXeqnCPd_YjdMfBP4eR5HsEzAs24_KRqXRCoLNIUbg0kEQNzupOzY5dx-4LuD1AKzaXcw"
}

You can also ask for an identity token to be returned in the authorization response using the response_type of id_token. Getting an identity token early in the process used to be really useful when the implicit flow was in heavy use by SPAs and when the hybrid flow was in use by web apps.

  • With the implicit flow, an identity token gave the client application a way of verifying that the access token it received in the authorization response had not been injected into the client, thanks to at_hash validation.
  • With the hybrid flow, identity tokens gave the client application something to validate before swapping the authorization code.

However, since PKCE & CORS are now widely available, it’s no longer common to use the id_token response type since these problems are now solved. Not using this response mode removes all tokens from the URL and, therefore, any chance of accidentally exposing PII to the browser history and server logs.

Identity token format

An identity token is always a JSON Web Token (JWT). It must always be signed (JWS) to prove that the token has not been tampered with, and, optionally, you can encrypt it (JWE), which is useful if the identity token contains identity data. As with other security tokens, the client application and the identity provider agree on keys using an external mechanism; keys are never in the tokens themselves.

The OpenID Connect specification recommends RS256 as the default signing algorithm, but better algorithms are available.

An identity token will contain the following claims:

  • iss – the issuer of the token. The issuer will always be the identifier of the identity provider who created the token. This identifier is always an https URL, such as https://identity.scottbrady91.com.
  • aud – the audience of the token. The audience will always include the client ID of the client application (relying party) who made the authorization request. Identity tokens can have other audiences too, but I have never seen a compelling use case for this.
  • sub – the subject identifier of the end-user. The subject identifier is their unique identifier, which the spec calls “locally unique”, meaning it is guaranteed to always represent the user within this client application. A different client application may receive a different sub claim than your client application so that a user cannot be tracked across them.
  • exp – when the token expires. The expiry doesn’t need to be all that long since the identity token only really needs to be valid long enough for it to be verified by the client application. It’s not uncommon to see identity tokens with a lifetime of 5 minutes, purely to account for clock skew. Identity token expiry has no relation to how long the user’s session will last for.
  • iat – when the token was created.

Other claims are optional, but they’re what makes the identity token so powerful:

  • 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.
  • amr – the authentication method reference. The amr is a collection of values that describe 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.
  • acr – the authentication context reference. The acr value 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).
  • nonce – the no more than once. The nonce is a random value generated by the client application and included in the authorization request. By repeating the nonce value back in the identity token, you allow the client application to verify that the identity token it received was generated in response to its request and not injected from a different session, stolen or otherwise. This is similar to SAML’s InReponseTo value. Officially, the use of a nonce is optional, but it really shouldn’t be
  • sid – the session identifier. This session identifier is the ID of the user’s session at the identity provider, which sees most of its use during single sign-out.

An identity token can also contain a hash of other data in the authorization or token response. This includes a hash of the access token (at_hash), authorization code (c_hash), and state (s_hash). By validating these hashes, the client application can prove that other parts of the response have also not been tampered with.

Should you put identity claims in the identity token?

Putting identity claims, such as name, preferred_username, or email, inside the identity token itself is possible. However, by including identity claims in the identity token, you run the risk of bloated tokens and, if you are using the id_token response type, you can even expose the identity data to attackers via the browser or server logs (think query string logging).

To get identity claims at the client, I recommend using OpenID Connect’s user info endpoint. The user info endpoint is a dedicated API hosted by the identity provider, which you call with your access token to get up-to-date, scoped identity data. If you want to avoid the extra API call, then, by all means, put the identity claims in the identity token, but do consider encrypting it first.

If you need identity claims at the protected resource (API), you would ideally associate the data with the access token, making it available via token introspection or even using a JWT format and including the claims in the token. Using a JWT access token does increase the token size; however, unlike identity tokens, an access token should never appear in URLs and, therefore, the browser history or server logs.

Example decoded identity token

Here’s the payload of a typical identity token decoded and formatted for readability.

{
  "iss": "https://idp.local", // who issued the token (the identity provider)
  "aud": "my_client_app", // the expected audience of the token (the client application)
  "sub": "5be86359073c434bad2da3932222dabe", // the subject ID (the unique ID for the user)
  "exp": 1615115277, // expiry
  "iat": 1615114977, // issued at
  "nbf": 1615114977, // not before 
  "auth_time": 1615114970, // when the user last authenticated
  "amr": [ // authentication method reference - how the user authenticated
    "pwd" // (they used a password)
  ],
  "nonce": "PdxaXTg_THsyna9wfYpsD-_qVpWjlyjjcl864K0wbxY", // the no more than once (detects id token injection)
  "at_hash": "2KxmL27CM9VC5eL6WkS1uw", // a hash of the access token (detects access token injection)
  "sid": "de71c582d82d286b5c6007d33da8184b" // the session ID
}

If you choose to include identity data in your identity token, you can also add these to the token payload. Remember that this should be the user’s identity rather than application-specific data or permissions. I recommend using the standard claims defined by OpenID Connect as guidance and using the standard scopes to authorize access to this identity data.

{
  // [previous identity token claims]
  "given_name": "Daniel",
  "family_name": "Dumile",
  "preferred_username": "MF DOOM"
}

Who should use an identity token?

The identity token is always intended for the client application that made the authorization request. As a result, the client application’s client ID will always be present in the identity token’s audience claim.

You should not use an identity token to authorize access to an API. Not only is the API not the intended audience of the identity token, but the identity token also has no concept of scoping. No scopes mean no security controls. If you can’t restrict what the token can do, then you have an all-or-nothing permission model. To access an API, you should be using OAuth’s access tokens, which are intended only for the protected resource (API) and come with scoping built-in. Maybe we shouldn’t call identity tokens “tokens” at all.

If you are adamant about using the identity token in an API to avoid calling the user info endpoint or using a JWT access token, then at least ensure that you also send the appropriate access token and that the API validates the identity token. Validation must check that the API is a valid audience of the identity token (aud claim) and, optionally, that the requesting client is the authorized party of the identity token (azp claim). But remember, identity tokens are not designed to be sent to an API.

Logout

The other use case for identity tokens is for single sign-out, where the client application sends the identity token to the end session endpoint to provide a hint about what session it is ending. The identity token is sent to the identity provider using the id_token_hint parameter. Don’t worry if the identity token has expired; it’s just a hint.

If you are encrypting identity tokens, be aware that you will need to decrypt & re-encrypt the identity token using the identity provider’s encryption key before using it for single sign-out. Otherwise, you’ll be sending it back in plaintext and be exposing PII.

Token exchange

You can also use identity tokens for token exchange (token type urn:ietf:params:oauth:token-type:id_token), which allows you to swap various kinds of security tokens at an authorization server’s token endpoint for a new access token. Since an identity token is relatively harmless (you cannot use it to gain unauthorized access to an API), it is a good candidate for swapping against a 3rd party’s authorization server.

You could swap an identity token from your identity provider with the trusted 3rd party to gain access tokens for a 3rd party API without the need to re-authenticate the user. Just make sure you don’t stuff your identity tokens full of PII, and you use the appropriate aud and azp claim values

API gateways and identity tokens

I’ve seen a strange pattern where an API gateway handles the token request, validates the identity token, but does not pass it back to the client application, instead only passing back the access token. Usually, this is in applications that aren’t really using OAuth or OpenID Connect and are instead using some custom flavor of OAuth’s password grant type (the temporary replacement for HTTP Basic authentication).

Unfortunately, this negates many of the client-side validation advantages that an identity token would have provided. And, if you are also using access tokens, it takes you back to the early days of OAuth, where the client application had no way of spotting injected access and refresh tokens.

The use of an API gateway does not change how you use identity tokens.

How to validate an identity token

To validate an identity token, you start with the basic JWT validation, checking that it’s a valid JWT, signed with a key you’ve agreed upon. You can use the key ID (kid header claim) value from the identity token as a hint, but make sure that the key is valid for the signing algorithm (alg header claim). Do not allow symmetric algorithms such as HS256, untrusted keys sent in the x5u, x5c, jku, or jwk headers, and don’t allow any of this alg: none malarkey.

If you agreed upon encrypted identity tokens. If you gave your public encryption key to the identity provider, then you must reject any unencrypted identity tokens.

After that, you validate the standard JWT claims. You can find the full validation steps in the OpenID Connect specification, in sections 3.1.3.7 (authorization code) and 3.2.2.11 (implicit), but in plain English, the client application asks the following questions:

  • Did the expected identity provider create the token? (iss)
  • Is the token intended for me? (aud and, if present, azp)
  • Is the token in date? (exp, nbf, and iat)
  • Am I expecting the token? And is the token in response to my request? (nonce)
  • Does the token contain the user’s identifier? (sub)

After that, it’s up to the client application to decide if the identity token is good enough to create a session or not. For example, it might check:

  • Did the user authenticate within an acceptable window of time? (auth_time). For instance, you might not allow a user to start a session in your backend administration portal unless they verified their identity within the last hour
  • Did the user authenticate to a high enough level? (acr and/or amr). For example, did they use MFA?

OpenID Connect does allow you to ask the identity provider for certain session conditions. For instance, the client application can include max_age, prompt, or acr_values in its authorization request. However, it is up to the client application to enforce these rules. For example, it should still check that the auth_time is valid and that the identity provider did not ignore its requested max_age.

Step-up authentication (elevation)

Sometimes, an identity token is good enough to start an initial session, but then the user wants to access a higher risk area of the client application that has different expectations as to how the user authenticated. For instance, they used the username & password to access the main website, and now they need to MFA in some way. As a result, you need to ask them to re-authenticate.

You can ask the user to re-authenticate by making a new authorization request to your identity provider, requesting step-up authentication using an acr_values parameter defining the authentication level you require. For example, you could use an ACR value of http://schemas.openid.net/pape/policies/2007/06/multi-factor, as defined in OpenID PAPE, asking for MFA.

When the identity provider processes this authorization request, it will see that the user’s current session does not meet the requirements of your ACR value and challenge the user with the next level of authentication.

Once the user re-authenticates to meet the new requirements, the client application will receive a new identity token. The client application must then validate the identity token using the usual process, verify that step-up authentication took place, and finally elevate the session (at least temporarily).

To learn more about standards-based step-up authentication, check out “Step-up authentication with OAuth and OpenID Connect”.

Summary

Identity tokens are one of the key features of OpenID Connect. The identity token represents the user’s authentication event, building on top of OAuth 2’s authorization requests and access tokens. Identity tokens are intended to be used by the client application (not APIs) and give them something to validate, ensuring the response is valid and enough information to decide if they want to start a session.

If you’ve seen other weird and wonderful uses of identity tokens, let me know in the comment section below. Otherwise, if you have any topic requests, reach out on Twitter, LinkedIn, or drop me an email.