Custom Token Exchange Early Access

As defined in RFC 8693, Custom Token Exchange allows you to enable applications to exchange their existing tokens for Auth0 tokens when calling the /oauth/token endpoint. This is useful for advanced integration use cases, such as:

  • Get Auth0 tokens for another audience

  • Integrate an external identity provider 

  • Migrate to Auth0

To learn more, read Example Use Cases and Code Samples.

To govern the token exchange and adjust it to the specific needs of your use case, you can define one or more Custom Token Exchange Profiles. Each profile establishes a one-to-one mapping between a subject_token_type, which provides information about the user for the transaction, and an Action. In that Action, you can write custom code to decode and validate subject tokens passed to the /oauth/token endpoint.

You can use Custom Token Exchange to authenticate users. For example, in an Action, you can apply the authorization logic for your use case and set the user for the transaction. Auth0 will then issue access, ID, and refresh tokens for the user.

Setup

Application

To use Custom Token Exchange, you must create a new application with the Auth0 Dashboard or the Management API. You can create multiple applications to use Custom Token Exchange.

When you create a new application:

1. By default, Custom Token Exchange is disabled. To enable Custom Token Exchange, use the Management API to make a POST call to Create a Client or a PATCH call to Update a Client. Set the allow_any_profile_of_type attribute under token_exchange to ["custom_authentication"]:

{
  "token_exchange": {
    "allow_any_profile_of_type": ["custom_authentication"]
  }
}

Was this helpful?

/

2. Enable the database connection or enterprise connection you want to use with Custom Token Exchange for the application.

3. Make sure your application is flagged as First-Party and it is configured as OIDC Conformant in Dashboard > Applications > Advanced Settings > OAuth.

Once you create the application, note the client_id and client_secret for later use when calling the /oauth/token endpoint.

Custom Token Exchange Profile

Each Custom Token Exchange Profile maps to a subject_token_type and is associated with an Action that contains the code logic for that use case.

Custom Token Exchange requests sent to the /oauth/token endpoint with a specific subject_token_type value map to the corresponding Custom Token Profile and route to the associated Action for processing.

To create a Custom Token Exchange Profile, first create an Action for the profile.

Create Action

In the Auth0 Dashboard:

  1. Navigate to Actions > Library.

2. Select Create Action > Build from Scratch.

3. In the Create Action dialog, enter a name and select the Custom Token Exchange trigger from the drop-down.

4. Select Create.

5. Deploy the Action.

When you deploy the Action, Auth0 assigns it an Action ID. You still need to add your custom logic to the Action, but first, get the Action ID to create the Custom Token Exchange Profile.

6. To get the Action ID in the Auth0 Dashboard, navigate to the URL of the browser window. The Action ID should be the last part of the URL, as shown in the following image:

You can also get the Action ID via the Management API. First, get a Management API token to consume the API. Then, make the following GET request to the /actions endpoint:

curl --location 'https://{{YOUR _TENANT}}/api/v2/actions/actions?actionName={{ACTION_NAME}}' \
--header 'Authorization: Bearer {{MANAGEMENT_API_TOKEN}}' \

Was this helpful?

/

You should receive the Action ID in the response body within actions[0].id. You need the Action ID to create the Custom Token Exchange Profile.

Create the Custom Token Exchange Profile

To create the Custom Token Exchange Profile, use the Management API to make a POST request with the following parameters to the /token-exchange-profiles endpoint:

curl --location 'https://{{YOUR _TENANT}}/api/v2/token-exchange-profiles' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer {{MANAGEMENT_API_TOKEN}}' \
--data '{
    "name": "{{PROFILE_NAME}}",
    "subject_token_type": "{{UNIQUE_PROFILE_TOKEN_TYPE_URI}}",
    "action_id": "{{ACTION_ID}}",
    "type": "custom_authentication"
}'

Was this helpful?

/

Parameter Description
subject_token_type Unique profile token type URI starting with https:// or urn

The following namespaces are reserved and you can’t use them:

  • http://auth0.com
  • https://auth0.com
  • http://okta.com
  • https://okta.com
  • urn:ietf
  • urn:auth0
  • urn:okta
action_id Action ID of Action associated with the Custom Token Profile.
type Should be set to custom_authentication.

If you've successfully created a Custom Token Exchange Profile, you should receive a response like the following:

{
  "id":"tep_9xqewuejpa2RTltf",
  "name":"{{PROFILE_NAME}}",
  "type":"custom_authentication",
  "subject_token_type":"{{UNIQUE_PROFILE_TOKEN_TYPE_URI}}",
  "action_id":"{{ACTION_ID}}",
  "created_at":"2025-01-30T13:19:00.616Z",
  "updated_at":"2025-01-30T13:19:00.616Z"
}

Was this helpful?

/

You are ready to start coding and testing your Custom Token Exchange to implement your use case.

Manage Custom Token Exchange Profile

To manage your Custom Token Exchange Profile, use the Management API to make requests to the /token-exchange-profiles endpoint.

To get all your Custom Token Exchange Profiles, make the following request. This endpoint supports checkpoint pagination in case you have several profiles.

curl --location 'https://{{YOUR _TENANT}}/api/v2/token-exchange-profiles' \
--header 'Authorization: Bearer {{MANAGEMENT_API_TOKEN}}' \

Was this helpful?

/

To update the name or subject_token_type of a Custom Token Exchange Profile, use the following PATCH request. You cannot modify the Action ID, although you can change the custom code it executes with the Actions editor:

curl --location --request PATCH 'https://{{YOUR _TENANT}}/api/v2/token-exchange-profiles/{{PROFILE_ID}}' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer {{MANAGEMENT_API_TOKEN}}' \
--data '{
    "name": "external-idp-migration",
    "subject_token_type": "urn:partner0:external-idp-migration"
}'

Was this helpful?

/

To delete a Custom Token Exchange Profile, make the following DELETE request:

curl --location --request DELETE 'https://{{YOUR _TENANT}}/api/v2/token-exchange-profiles/{{PROFILE_ID}}' \
--header 'Authorization: Bearer {{MANAGEMENT_API_TOKEN}}' \
--data ''

Was this helpful?

/

Actions API

Custom Token Exchange vs Post Login Action

The Custom Token Exchange Action, available as a part of Custom Token Exchange Early Access, can use the new API methods listed in Use the Actions API.

For other needs, such as adding custom claims to access tokens, your Post Login Actions trigger executes after the Custom Token Exchange Action runs for the user that you set for the transaction, thus giving you the same functionality as other login flows. 

To identify a transaction that uses the token exchange grant type, look for an event.transaction.protocol value equal to oauth2-token-exchange in your Post Login Action. Because the token exchange grant type is used by both the Custom Token Exchange and Native Social Login transactions, you can use the value of the subject_token_type to distinguish between the two, where the subject_token_type corresponds to one of your Custom Token Exchange Profiles.

Use the Actions API

Auth0 provides a number of API methods to use with your Token Exchange Action. You should implement an Action that decodes and validates the subject token based on the subject_token_type. This will provide you with information about the user for the transaction. With this information, your code should also enforce the authorization policy needed for the transaction. Once you are sure the transaction can proceed, you can confirm it by setting the corresponding user. Auth0 will then issue access, ID, and refresh tokens for this user. You can think of this as a way to authenticate users.

Each Custom Token Exchange transaction generates a tenant event log. Successful transactions generate event logs of type secte, while failed transactions generate event logs of type fecte. Use these log types to help you understand any errors you may receive. Errors from the /oauth/token endpoint reveal less details.

api.authentication.setUserById(user_id)

Sets user attributes based on a specified user ID for any connection type. This allows you to specify an existing user without updating the profile. This method fails if the user does not exist or is blocked.

Parameter Description
user_id The user ID, such as auth0|55562040asf0aef.

exports.onExecuteCustomTokenExchange = async (event, api) => {

  // 1. Validate subject_token
  const subject_token = await validateToken(event.transaction.subject_token, jwksUri);

  // 2.  Apply your authorization policy on the user
  const isAuthorized = await authorizeAccess(subject_token.sub);
  if (!isAuthorized) {
    api.access.deny('Unauthorized_login', 'User cannot login due to reason: X');
  }

  // 3. Set the user for the transaction
  api.authentication.setUserById(subject_token.sub);

  return;
};

Was this helpful?

/

api.authentication.setUserByConnection(connection_name, user_profile, options)

Sets a user and their associated profile attributes in a specified connection. This is equivalent to a user logging into this connection and the federated IdP returning the specified user profile. You can configure whether this operation should create the user if it does not exist, and whether it should update the profile using the provided user profile attributes.

The login count will be incremented for each user that is logged in via setUserByConnection(). This method always fails for blocked users.

Parameter Description
connection_name The name of the connection where the user profile will be set. Limited to 512 characters.
user_profile An object containing the user profile attributes to be set. Limited to 24 properties.
options An object specifying update and creation behavior.

{updateBehavior: 'replace' | 'none',creationBehavior: 'create_if_not_exists' | 'none',}

If the user exists, updateBahaviour does the following:
  • replace: the user attributes and the user_id for the provided connection are replaced (existing user attributes that are not provided will be removed from the user. Partial updates are not supported).
  • none: if the user exists, the profile is not updated. If the user does not exist, it will be created with the provided profile attributes depending on creationBehavior configuration.
  • If the user does not exist, creationBehavior does the following:
    • create_if_not_exists: create the user
    • none: does not create the user and return with an error

exports.onExecuteCustomTokenExchange = async (event, api) => {

  // 1. Validate subject_token
  const subject_token = await validateToken(event.transaction.subject_token, jwksUri);

  // 2.  Apply your authorization policy on the user
  const isAuthorized = await authorizeAccess(subject_token.sub);
  if (!isAuthorized) {
    api.access.deny('Unauthorized_login', 'User cannot login due to reason: X');
  }

  // 3. Set the user for the transaction
  api.authentication.setUserByConnection(
    'My Connection',
    {
      user_id: subject_token.sub,
      email: subject_token.email,
      email_verified: subject_token.email_verified,
      phone_number: subject_token.phone_number,
      phone_verified: subject_token.phone_number_verified,
      username: subject_token.preferred_username,
      name: subject_token.name,
      given_name: subject_token.given_name,
      family_name: subject_token.family_name,
      nickname: subject_token.nickname,
      verify_email: false
    },
    {
      creationBehavior: 'create_if_not_exists',
      updateBehavior: 'none'
    }
  );

  return;
};

Was this helpful?

/

Supported user profile attributes

The setUserByConnection() method allows you to set profile attributes supported by the Update a User endpoint:

  • user_id (required): user's unique identifier for this connection/provider. It is typically the user ID provided by the external identity provider for the connection. This is the only required parameter when both creationBehaviour and updateBehaviour are set to none

  • email

  • email_verified. Defaults to false.

  • username 

  • phone_number 

  • phone_verified. Defaults to false.

  • name

  • given_name

  • family_name

  • nickname

  • picture

Use metadata fields if you need to set attributes not considered in the above list.

Supported connection strategies

The current version provides support for the following connection strategies. The setUserByConnection() method fails for other strategies. Please contact Auth0 support to request adding support for other strategies.

Enterprise connections:

Social connections:

  • Custom Social Connections 

  • Google

  • Apple

  • Facebook

  • Github

  • Windowslive

Creation behavior

Users are dynamically created only when creationBehavior is set to create_if_not_exists.

When creating users:

  • You must provide an identifier as configured by your connection. By default, an email is required.

  • For connections that use Flexible Identifiers and Attributes, you may provide a username and phone number if the corresponding attribute is enabled for the connection.

  • For connections that don’t use Flexible Identifiers and Attributes: 

  • You may specify email_verified and phone_verified.

A random password is generated for users dynamically created in Auth0 database connections. There are different options to trigger a password reset flow when needed after user creation.

Update behavior

User profile is updated only when updateBehavior is set to replace.

The following attributes cannot be modified and Auth0 returns an error when trying to change its value: 

  • email

  • username

  • phone_number

  • email_verified

  • phone_verified

Email verification

Auth0 automatically sends verification emails when you create a user with email_verified=false. You can override this behavior by specifying verify_email=false as a user profile attribute. It won’t be stored as part of the user profile.

exports.onExecuteCustomTokenExchange = async (event, api) => {

  // Validate subject_token
  const subject_token = await validateToken(event.transaction.subject_token, jwksUri);

  // Create a user but don't verify email
  api.authentication.setUserByConnection(
    'My Connection',
    {
      user_id: subject_token.sub,
      email: subject_token.email,
      email_verified: false,
      verify_email: false
    },
    {
      creationBehavior: 'create_if_not_exists',
      updateBehavior: 'none'
    }
  );

  return;
};

Was this helpful?

/

If you have configured and enabled a welcome email template, Auth0 automatically sends a welcome email to newly created users when no email verification is sent.

Set metadata

Unlike the Update a User endpoint, the setUserByConnection() method does not allow you to set user or application metadata. Instead, you can use api.user.setAppMetadata. To learn how to correctly use metadata, read How Metadata Works in User Profiles. For metadata best practices, read How to Manage User Metadata with the Post-login Trigger.

api.user.setAppMetadata(name, value)

Sets application metadata for the user that is logging in.

This method follows a merge behavior, so you can indicate the new attributes to add or the ones to be updated without affecting the existing ones. To remove an attribute, set its value to null.

Parameters Description
name String. The name of the metadata property.
value String, object or array. The name of the metadata property.

exports.onExecuteCustomTokenExchange = async (event, api) => {
  // Validate subject_token
  const subject_token = await validateToken(event.transaction.subject_token, jwksUri);

  // set the user for the transaction
  api.authentication.setUserById(subject_token.id);

  // set user group based on info contaiened in subject_token
  api.user.setAppMetadata('group', subject_token.group);

  return;
};

Was this helpful?

/

api.user.setUserMetadata(name, value)

Sets general metadata for the user that is logging in.

This method follows a merge behavior, so you can indicate the new attributes to add or the ones to be updated without affecting the already existing ones. To remove an attribute, set its value to null.

Parameters Description
name String. The name of the metadata property.
value String, object or array. The name of the metadata property.

exports.onExecuteCustomTokenExchange = async (event, api) => {
  // Validate subject_token
  const subject_token = await validateToken(event.transaction.subject_token, jwksUri);

  // set the user for the transaction
  api.authentication.setUserById(subject_token.id);

  // set user preferred_locale based on info contaiened in subject_token
  api.user.setUserMetadata('preferred_locale', subject_token.locale);

  return;
};

Was this helpful?

/

api.access.deny(code, reason)

Denies the login transaction and returns an error to the caller.

Parameter Description
code A string returned in the error property in the response.

Two standard error codes can be used:
  • invalid_request: Returns a 400 status code
  • server_error: Returns 500 status code

If you use your own error code, it returns a 400 status code.
reason A string returned in the error_description property in the response.

exports.onExecuteCustomTokenExchange = async (event, api) => {

  // 1. Validate subject_token
  const subject_token = await validateToken(event.transaction.subject_token, jwksUri);

  // 2.  Apply your authorization policy on the user
  const isAuthorized = await authorizeAccess(subject_token.sub);
  if (!isAuthorized) {
    api.access.deny('Unauthorized_login', 'User cannot login due to reason: X');
  }

  // if user is authorized, go on as indicated here

};

Was this helpful?

/

api.access.rejectInvalidSubjectToken(reason)

Denies the transaction and increments the counter of failed attempts for the external IP from which the request is coming. The Custom Token Exchange request is rejected with a 400 Bad Request error response with the error code invalid_request

When the maximum number of failed attempts is reached, Auth0 blocks traffic for a period of time for all Custom Token Exchange requests coming from that IP with a 429 Too Many Requests error response with error code too_many_attempts. To learn more read Attack Protection.

Use this method whenever you receive a Custom Token Exchange request with a subject token that is not properly signed/encrypted or expired, or under any circumstance that points to any non-legitimate usage such as in a spoofing or replay attack. This allows Auth0 to activate Suspicious IP Throttling protection as per your configuration.

By default, Suspicious IP Throttling allows for 10 max attempts at a rate of 6 attempts/hour. To learn more, read Attack Protection.

Parameter Description
reason A string returned in the error_description property in the response

exports.onExecuteCustomTokenExchange = async (event, api) => {

  try {
    // Validate subject_token
    const subject_token = await validateToken(event.transaction.subject_token, jwksUri);
    // set the user for the transaction
    api.authentication.setUserById(subject_token.id);

  } catch (error) {
    if (error.message === 'Invalid Token') {
      // If specifically the problem is the subject_token is invalid
      console.error('Invalid Token error');
      api.access.rejectInvalidSubjectToken('Invalid subject_token');
    } else {
      // if there is any other unexpected error, throw a server error
      throw error;
    }
  }

};

Was this helpful?

/

api.cache

Store and retrieve data that persists across executions.

These methods are useful for caching data used for subject token validation, such as public keys for signature validation. This can help improve performance when fetching the keys from a jwks-uri.

api.cache.delete(key)

Delete a record describing a cached value at the supplied key if it exists.

Returns a CacheWriteResult object with type: "success" if a value was removed from the cache. A failed operation returns type: "error". For errors, the returned object will have a code property that indicates the nature of the failure.

Parameter Description
key String. The key of the record stored in the cache.

api.cache.get(key)

Retrieve a record describing a cached value at the supplied key, if it exists. If a record is found, the cached value can be found at the value property of the returned object.

Returns a cache record if an item is found in the cache for the supplied key. Cache records are objects with a value property holding the cached value as well as an expires_at property indicating the maximum expiry of the record in milliseconds since the Unix epoch.

Important: This cache is designed for short-lived, ephemeral data. Items may not be available in later transactions even if they are within their supplied lifetime.

Parameter Description
key String. The key of the record stored in the cache.

api.cache.set(key, value, [options])

Store or update a string value in the cache at the specified key.

Values stored in this cache are scoped to the Trigger in which they are set. They are subject to the Actions Cache Limits.

Values stored in this way will have lifetimes of up to the specified ttl or expires_at values. If no lifetime is specified, a default lifetime of 15 minutes will be used. Lifetimes may not exceed the maximum duration listed at Actions Cache Limits.

Returns CacheWriteSuccess if the values are stored successfully. Otherwise, you will receive CacheWriteError.

Parameter Description
key String. The key of the record stored in the cache.
value String. The value of the record to be stored.
options Optional object. Options for adjusting cache behavior.
options.expires_at Optional number. The absolute expiry time in milliseconds since the unix epoch. While cached records may be evicted earlier, they will never remain beyond the supplied expires_at.

Note: This value should not be supplied if a value was also provided for ttl. If both options are supplied, the earlier expiry of the two will be used.
options.ttl Optional number. The time-to-live value of this cache entry in milliseconds. While cached values may be evicted earlier, they will never remain beyond the supplied ttl.

Note: This value should not be supplied if a value was also provided for expires_at. If both options are supplied, the earlier expiry of the two will be used.

Actions Event

In addition to new Actions API methods, you can use data in the Actions Event to learn about the context of the Token Exchange request, such as the subject token, IP address, client, and more.

Property Type Example
client
client_id string HOVc2PDFTH7eahimN4yNCo8mOtjfNjLV
name string My Web App
metadata object {“foo”: “bar” }
tenant
id string dev_1234
request
geoip object { … geoip object}
hostname string dev_1234.us.auth0.com
ip string 123.42.42.34
user_agent string Mozilla/5.0
language string en
body object { // raw req.body }
method string POST
transaction
subject_token_type string urn://cic-migration-token
subject_token string 41598922a1745f7af70
requested_scopes string[] [“openid”, “email”]
resource_server
id string http://acme-api/v1/profile

Deploy the Action

After you create your Token Exchange Action using the above API and Event objects, deploy the changes by clicking Deploy at the top of the page.

Call Token Exchange

To use Custom Token Exchange, make a POST request to the /oauth/token endpoint with the following parameters. Remember that:

  • subject_tokens used with Custom Token Exchange can be any token format or type, as long as your Action code can interpret them.

  • Each subject_token_type maps to a specific Custom Token Exchange Profile and is associated with a specific Action that will be executed to control that transaction.

Parameter Description
grant_type For Custom Token Exchange, use urn:ietf:params:oauth:grant-type:token-exchange.
subject_token_type The type of the subject token. For Custom Token Exchange, this can be any URI scoped under your own ownership, such as http://acme.com/legacy-token or urn:acme:legacy-token.

The following namespaces are reserved and cannot be used:
  • http://auth0.com
  • https://auth0.com
  • http://okta.com
  • https://okta.com
  • urn:ietf
  • urn:auth0
  • urn:okta
subject_token The subject token, which your action should validate and use to identify the user.
client_id The client ID of the application you are using for the Token Exchange. As for other grant types, you can also pass the client ID in the Authorization header using HTTP Basic Auth.
client_secret The client secret of the application you are using for the Token Exchange. As for other grant types, you can also pass the client secret in the Authorization header using HTTP Basic Auth.

Other alternatives are also available as explained in Auth0 Authentication API reference docs.

Note Custom Token Exchange can be used by public Applications. Make sure to read Attack Protection in that case.
audience The API identifier defined in Auth0.
scope The OAuth2 scope parameter.

Other extension parameters are ignored, although they are included in the event.request.body in the corresponding Action.

Sample request

curl --location 'https://{{YOUR_TENANT}}/oauth/token' \
--header 'Content-Type: application/x-www-form-urlencoded' \
--data-urlencode 'grant_type=urn:ietf:params:oauth:grant-type:token-exchange' \
--data-urlencode 'audience=https://api.acme.com' \
--data-urlencode 'scopes=openid offline_access acme-scope1 acme-scope2' \
--data-urlencode 'subject_token_type=urn:acme:external-idp-migration' \
--data-urlencode 'subject_token=t8e7S2D9trQm73e .... iqBR3GjxDtbDVjpfQU' \
--data-urlencode 'client_id={{CLIENT_ID}}' \
--data-urlencode 'client_secret={{CLIENT_SECRET}}'

Was this helpful?

/

Attack Protection

To protect against spoofing and replay attacks, in which a bad actor tries to guess or reuse a subject token, Custom Token Exchange incorporates support for Suspicious IP Throttling. This enables you to specifically signal from your code in Actions when a subject token is invalid, so Auth0 can count the failed attempts sent from that external IP.

When the number of failed attempts from an IP address reaches a pre-configured threshold, Auth0 blocks traffic for a Custom Token Exchange request coming from that IP with the following error:

HTTP/1.1 429 Too Many Requests
Content-Type: application/json
{
    "error": "too_many_attempts",
    "error_description": "We have detected suspicious login behavior and further attempts will be blocked. Please contact the administrator."
}

Was this helpful?

/

The IP address can start making requests again after a configured period of time.

Although it is recommended for all cases, it is especially important that you activate and properly configure Suspicious IP Throttling if you want to use Custom Token Exchange with Native Applications or Single Page Applications. Because non-confidential applications like Native and SPAs can’t securely store secrets to authenticate themselves, it is easier for attackers to try to guess or re-use stolen or leaked subject tokens.

To properly use Suspicious IP Throttling protection, remember to use api.access.rejectInvalidSubjectToken in your Action code whenever the received subject token does not pass strong validation.

Suspicious IP Throttling is activated by default for Auth0 tenants. To learn more about how to (de)activate and configure it, read Suspicious IP Throttling. When activated, the default settings for Custom Token Exchange will be applied:

  • Threshold: 10. Maximum number of failed attempts for an IP address.

  • Throttling rate: 6 per hour. One additional attempt will become available after every 10 minutes until the threshold is refilled.

You can configure a custom threshold and throttling rate for Custom Token Exchange with the Management API.

First, get a Management API token to consume the API. Then, send the following GET request to the Get Suspicious IP Throttling settings endpoint:

curl --location 'https://{{YOUR _TENANT}}/api/v2/attack-protection/suspicious-ip-throttling' \
--header 'Authorization: Bearer {{MANAGEMENT_API_TOKEN}}' \

Was this helpful?

/

You will receive a response like the following:

{
  "enabled": true,
  "shields": [
    "admin_notification",
    "block"
  ],
  "allowlist": [],
  "stage": {
    "pre-login": {
      "max_attempts": 100,
      "rate": 864000
    },
    "pre-user-registration": {
      "max_attempts": 50,
      "rate": 1200
    },
    "pre-custom-token-exchange": {
      "max_attempts": 10,
      "rate": 600000
    }
  }
}

Was this helpful?

/

Use the following PATCH request to update the pre-custom-token-exchange stage with the needed values. Note that the rate is the interval of time in milliseconds at which new attempts are granted.

curl --location --request PATCH 'https://{{YOUR _TENANT}}/api/v2//attack-protection/suspicious-ip-throttling' \
--header 'Content-Type: application/json' \
--header 'Authorization: Bearer {{MANAGEMENT_API_TOKEN}}' \
--data '{"stage":{"pre-custom-token-exchange":{"max_attempts":10,"rate":600000}}}'

Was this helpful?

/

Example Use Cases and Code Samples

You can use Custom Token Exchange to solve advanced integration scenarios where normal federated login strategies based on redirecting the end user cannot be applied due to technical or user experience constraints. The code provided for the use cases is incomplete and only aims at showing the logical steps you can follow with your code to address the use case. Refer to code samples for more detailed code examples.

This section describes example use cases and specific code samples with recommendations for implementing your scenario. To illustrate the use cases, we will use GearUp, a fictional car rental service company.

Use Case: Seamless migration into Auth0

GearUp has a mobile App used by millions of people and needs to modernize their Identity solution so they’ve decided to switch to Auth0. However, they want to avoid forcing users to re-authenticate as they migrate from their legacy identity provider, or IdP, as that adds friction to the user experience.

To solve this, and to limit risks, GearUp is migrating incrementally. For each user, they wish to exchange the refresh token from their legacy IdP for an Auth0 access token, refresh token and ID token set. This allows their app to seamlessly start using Auth0 as the IdP for this user, as well as consume GearUp APIs using Auth0-issued tokens. Once the exchange is done for all users, the app will be fully migrated and the old IdP can be disconnected–all without impacting end users and GearUp’s business.

As a prerequisite, GearUp has done a bulk user import into their Auth0 tenant and the mobile app has a valid legacy refresh token for each user to be migrated.

  1. The mobile app makes a request to Auth0 to exchange the legacy refresh token, setting it as the subject token.

  2. The corresponding Custom Token Exchange profile Action executes. It validates the refresh token with the legacy IdP and gets the external user ID from the user profile. It then applies the required authorization policy and finally sets the user.

  3. Auth0 responds with Auth0 access token, ID token, and refresh token.

  4. The mobile app can now use the Customer APIs using Auth0 tokens without the user having to re-authenticate.

The following code sample shows how to implement this in the Custom Token Exchange Action. In this case, since user profiles were already imported into an Auth0 database connection:

  • We don’t want to create the user.

  • We don’t want to update the user profile.

We use the external IdP user ID to set the user in the corresponding connection.

/**
* Handler to be executed while executing a custom token exchange request
* @param {Event} event - Details about the incoming token exchange request.
* @param {CustomTokenExchangeAPI} api - Methods and utilities to define token exchange process.
*/
exports.onExecuteCustomTokenExchange = async (event, api) => {

 // 1. VALIDATE the refresh_token received in the subject_token by using it to get
 // the UserProfile from the external IdP
 const { isValid, user } = await getUserProfile(
   event.transaction.subject_token,
   event.secrets.CLIENT_SECRET,
 );

 if (!isValid) {
   // Mark the subject token as invalid and fail the transaction.
   api.access.rejectInvalidSubjectToken("Invalid subject_token");
 } else {
   // 2. Apply your AUTHORIZATION POLICY as required to determine if the request is valid.
   // Use api.access.deny() to reject the transaction in those cases.

   // 3. When we have the profile, we SET THE USER in the target connection
   api.authentication.setUserByConnection(
     connectionName,
     {
       // only the user_id in the connection is needed, as we are not
       // creating nor updating the user
       user_id: user.sub,
     },
     {
       creationBehavior: "none",
       updateBehavior: "none",
     },
   );
 }
};

/**
* Exchange the refresh token and load the user profile from the legacy IdP
* @param {string} refreshToken
* @param {string} clientSecret
* @returns {Promise<{ isValid: boolean, user?: object }>} If the refresh token was exchanged successfully, returns the user profile
*/
async function getUserProfile(refreshToken, clientSecret) {
 // Add your code here. REFER TO CODE SAMPLES FOR DETAILED EXAMPLES
}

Was this helpful?

/

Read code samples for a more detailed example on how to validate opaque refresh token with the legacy IdP.

Use Case: Re-use an external authentication provider

Another use case involves GearUp partnering with Air0, a leading travel provider, to offer their car rental services directly within the Air0 single-page application. GearUp offers a JavaScript library that encapsulates the use of their APIs. This way GearUp’s APIs can easily be consumed by Air0’s website where car rental services are being offered. 

Once again, the solution needs to be invisible to end users by avoiding re-authentication to GearUp. To solve this problem, GearUp’s JavaScript library can perform a token exchange using the external Air0 ID token as the input. This results in an Auth0 access token that is generated and associated with the corresponding GearUp user based on their email address. Once the GearUp library gets the access token, it can start using GearUp’s APIs to offer car rental services directly within Air0’s website.

As a prerequisite, GearUp has set up Air0 IdP as a federated enterprise or social connection, so the user can authenticate via federate login or alternatively via Custom Token Exchange as follows:

  1. The Single Page App gets the ID token from the external IdP once the user authenticates.

  2. It then requests the exchanges of the ID token, setting it as the subject token.

  3. The corresponding Custom Token Exchange profile Action executes. It validates the ID token and gets the user ID and other profile attributes from the token. It then applies the required authorization policy and finally sets the user.

  4. Auth0 responds with Auth0 access token, ID token and refresh token.

  5. The javascript code running in the SPA can now use the Customer APIs using Auth0 tokens without the user having to re-authenticate. 

The following code exemplifies how to implement this in the Custom Token Exchange Action. In this case:

  • We use the external IdP user ID to set the user in the corresponding connection.

  • We want to create the user if they don’t yet exist.

  • We don’t want to replace the user profile if a more complete set of attributes is obtained via federated login, in case the user already exists.

  • We don't want to verify emails when users are created.

const jwksUri = "https://example.com/.well-known/jwks.json";

/**
 * Handler to be executed while executing a custom token exchange request
 * @param {Event} event - Details about the incoming token exchange request.
 * @param {CustomTokenExchangeAPI} api - Methods and utilities to define token exchange process.
 */
exports.onExecuteCustomTokenExchange = async (event, api) => {

  // 1. VALIDATE the id_token received in the subject_token
  const { isValid, payload } = await validateToken(
    event.transaction.subject_token,
  );

  if (!isValid) {
    // Mark the subject token as invalid and fail the transaction.
    api.access.rejectInvalidSubjectToken("Invalid subject_token");
  } else {
    // 2. Apply your AUTHORIZATION POLICY as required to determine if the request is valid.
    // Use api.access.deny() to reject the transaction in those cases.

    // 3. SET THE USER in the target connection.
    // We don't want to verify emails when users are created
    // This example assumes subject_token (id_token) contains standard OIDC claims. Other custom mappings
    // are also possible.
    api.authentication.setUserByConnection(
      'Enterprise-OIDC',
      {
          user_id: formattedUserId,
          email: subject_token.email,
          email_verified: subject_token.email_verified,
          phone_number: subject_token.phone_number,
          phone_verified: subject_token.phone_number_verified,
          username: subject_token.preferred_username,
          name: subject_token.name,
          given_name: subject_token.given_name,
          family_name: subject_token.family_name,
          nickname: subject_token.nickname,
          verify_email: false
      },
      {
          creationBehavior: 'create_if_not_exists',
          updateBehavior: 'none'
      }
    );
  }

  /**
   * Validate the subject token
   * @param {string} subjectToken
   * @returns {Promise<{ isValid: boolean, payload?: object }>} Payload of the token
   */
  async function validateToken(subjectToken) {
    // Add your code here. REFER TO CODE SAMPLES FOR DETAILED EXAMPLES
  }
};

Was this helpful?

/

Read code samples for a more detailed example on how to securely validate JWTs.

Use Case: Get Auth0 tokens for another audience

GearUp wants to improve how it authorizes calls between its internal microservices to serve API requests. It wants a centralized policy controlling the resources that each service can consume. This can also be solved using Token Exchange.

When the API request first arrives at service A, it exchanges the received access token for a new one that allows it to consume service B as the new audience. If the authorization policy governing the token exchange allows it, service A gets the new token back and can now consume service B. The user ID is kept unchanged in the new token, so the proper user context is retained throughout the process.

The GearUp application has initially obtained an access token to consume API A on behalf of a user:

  1. The app sends the request with the initial access token to API A.

  2. API A backend service validates the access token and requests to exchange by setting it as the subject token for a new access token to consume API B.

  3. The corresponding Custom Token Exchange profile Action executes. It validates the access token and gets the Auth0 user ID from the token. It then applies the required authorization policy and finally sets the user.

  4. Auth0 responds with an Auth0 access token to consume the API B audience.

  5. API A backend service calls API B using the new access token, which is still associated with the same user.

The following code exemplifies how to implement this in the Custom Token Exchange Action. In this case:

  • We use the Auth0 user ID to set the user, so there is no need to set this in the scope of any connection.

  • We don’t want to create or update the user.

Refer to Validate JWTs signed with asymmetric keys for expanded code samples on this use case.

const jwksUri = "https://example.com/.well-known/jwks.json";

/**
 * Handler to be executed while executing a custom token exchange request
 * @param {Event} event - Details about the incoming token exchange request.
 * @param {CustomTokenExchangeAPI} api - Methods and utilities to define token exchange process.
 */
exports.onExecuteCustomTokenExchange = async (event, api) => {
  // 1. VALIDATE the access_token received in the subject_token
  const { isValid, payload } = await validateToken(
    event.transaction.subject_token,
  );

  if (!isValid) {
    // Mark the subject token as invalid and fail the transaction.
    api.access.rejectInvalidSubjectToken("Invalid subject_token");
  } else {
    // 2. Apply your AUTHORIZATION POLICY as required to determine if the request is valid.
    // Use api.access.deny() to reject the transaction in those cases.

    // 3. SET THE USER
    api.authentication.setUserById(payload.sub);
  }

  /**
   * Validate the subject token
   * @param {string} subjectToken
   * @returns {Promise<{ isValid: boolean, payload?: object }>} Payload of the token
   */
  async function validateToken(subjectToken) {
    // Add your code here. REFER TO CODE SAMPLES FOR DETAILED EXAMPLES
  }
};

Was this helpful?

/

Read code samples for a more detailed example on how to securely validate JWTs.

Code samples

The following code samples show best practices for common scenarios for validating incoming subject tokens in a secure and performant way.

Use asymmetric algorithms and keys whenever you can as you don’t need to share any secret with Auth0. This also simplifies key rotation, such as when exposing a JWKS URI endpoint to advertise applicable public keys.

Validate JWTs signed with asymmetric keys

Consider the following recommendations:

  • Use Actions api.cache methods to avoid having to fetch the signing keys for each transaction.

  • Adhere to RFC8725 best practices

  • Use RS*, PS*, ES* or Ed25519 algorithms

  • Do not use or accept the none algorithm

  • Use RSA with a minimum length of 2048 bits.

const { jwtVerify } = require("jose");

const jwksUri = "https://example.com/.well-known/jwks.json";
const fetchTimeout = 5000; // 5 seconds

const validIssuer = "urn:my-issuer"; // Replace with your issuer

/**
 * Handler to be executed while executing a custom token exchange request
 * @param {Event} event - Details about the incoming token exchange request.
 * @param {CustomTokenExchangeAPI} api - Methods and utilities to define token exchange process.
 */
exports.onExecuteCustomTokenExchange = async (event, api) => {
  const { isValid, payload } = await validateToken(
    event.transaction.subject_token,
  );

  // Apply your authorization policy as required to determine if the request is valid.
  // Use api.access.deny() to reject the transaction in those cases.

  if (!isValid) {
    // Mark the subject token as invalid and fail the transaction.
    api.access.rejectInvalidSubjectToken("Invalid subject_token");
  } else {
    // Set the user in the current request as authenticated, using the user ID from the subject token.
    api.authentication.setUserById(payload.sub);
  }

  /**
   * Validate the subject token
   * @param {string} subjectToken
   * @returns {Promise<{ isValid: boolean, payload?: object }>} Payload of the token
   */
  async function validateToken(subjectToken) {
    try {
      const { payload, protectedHeader } = await jwtVerify(
        subjectToken,
        async (header) => await getPublicKey(header.kid),
        {
          issuer: validIssuer,
        },
      );

      // Perform additional validation on the token payload as required

      return { isValid: true, payload };
    } catch (/** @type {any} */ error) {
      if (error.message === "Error fetching JWKS") {
        throw new Error("Internal error - retry later");
      } else {
        console.log("Token validation failed:", error.message);
        return { isValid: false };
      }
    }
  }

  /**
   * Get the public key to use for key verification. Load from the actions cache if available, otherwise
   * fetch the key from the JWKS endpoint and store in the cache.
   * @param {string} kid - kid (Key ID) of the key to be used for verification
   * @returns {Promise<Object>}
   */
  async function getPublicKey(kid) {
    const cachedKey = api.cache.get(kid);
    if (!cachedKey) {
      console.log(`Key ${kid} not found in cache`);
      const key = await fetchKeyFromJWKS(kid);
      api.cache.set(kid, JSON.stringify(key), { ttl: 600000 });
      return key;
    } else {
      return JSON.parse(cachedKey.value);
    }
  }

  /**
   * Fetch public signing key from the provided JWKS endpoint, to use for token verification
   * @param {string} kid - kid (Key ID) of the key to be used for verification
   * @returns {Promise<object>}
   */
  async function fetchKeyFromJWKS(kid) {
    const controller = new AbortController();
    setTimeout(() => controller.abort(), fetchTimeout);

    /** @type {any} */
    const response = await fetch(jwksUri);

    if (!response.ok) {
      console.log(`Error fetching JWKS. Response status: ${response.status}`);
      throw new Error("Error fetching JWKS");
    }
    const jwks = await response.json();
    const key = jwks.keys.find((key) => key.kid === kid);
    if (!key) {
      throw new Error("Key not found in JWKS");
    }
    return key;
  }
};

Was this helpful?

/

Validate JWTs signed with symmetric keys

Consider the following recommendations:

  • Use Actions Secrets to securely store your symmetric secrets.

  • Adhere to RFC8725 best practices

  • Use secure algorithms such as HS256, along with high entropy random secrets (e.g. of at least 256 bits long)

const { jwtVerify } = require("jose");

const validIssuer = "urn:my-issuer"; // Replace with your issuer

/**
 * Handler to be executed while executing a custom token exchange request
 * @param {Event} event - Details about the incoming token exchange request.
 * @param {CustomTokenExchangeAPI} api - Methods and utilities to define token exchange process.
 */
exports.onExecuteCustomTokenExchange = async (event, api) => {
  // Initialize the shared symmetric key from Actions Secrets
  const encoder = new TextEncoder();
  const symmetricKey = encoder.encode(event.secrets.SHARED_SECRET);

  const { isValid, payload } = await validateToken(
    event.transaction.subject_token,
    symmetricKey,
  );

  // Apply your authorization policy as required to determine if the request is valid.
  // Use api.access.deny() to reject the transaction in those cases.

  if (!isValid) {
    // Mark the subject token as invalid and fail the transaction.
    api.access.rejectInvalidSubjectToken("Invalid subject_token");
  } else {
    // Set the user in the current request as authenticated, using the user ID from the subject token.
    api.authentication.setUserById(payload.sub);
  }
};

/**
 * Validate the subject token
 * @param {string} subjectToken
 * @param {Uint8Array} symmetricKey
 * @returns {Promise<{ isValid: boolean, payload?: object }>} Payload of the token
 */
async function validateToken(subjectToken, symmetricKey) {
  try {
    // Validate token is correctly signed with the shared symmetric key
    // It also checks it is not expired as long as it includes an 'exp' attribute.
    const { payload, protectedHeader } = await jwtVerify(
      subjectToken,
      symmetricKey,
      {
        issuer: validIssuer,
      },
    );

    return { isValid: true, payload };
  } catch (/** @type {any} */ error) {
    console.log("Token validation failed:", error.message);
    return { isValid: false };
  }
}

Was this helpful?

/

Validate opaque token with an external service

Use Action Secrets to securely store your external IdP client secret.

const tokenEndpoint = "EXTERNAL_TOKEN_ ENDPOINT";
const userInfoEndpoint = "EXTERNAL_USER_INFO_ENDPOINT";
const clientId = "EXTERNAL_CLIENT_ID";
const connectionName = "YOUR_CONNECTION_NAME";
const fetchTimeout = 5000; // 5 seconds

/**
 * Handler to be executed while executing a custom token exchange request
 * @param {Event} event - Details about the incoming token exchange request.
 * @param {CustomTokenExchangeAPI} api - Methods and utilities to define token exchange process.
 */
exports.onExecuteCustomTokenExchange = async (event, api) => {
  const { isValid, user } = await getUserProfile(
    event.transaction.subject_token,
    event.secrets.CLIENT_SECRET,
  );

  if (!isValid) {
    // Mark the subject token as invalid and fail the transaction.
    api.access.rejectInvalidSubjectToken("Invalid subject_token");
    return;
  }

  // Apply your authorization policy as required to determine if the request is valid.
  // Use api.access.deny() to reject the transaction in those cases.

  // When we have the profile, we set the user in the target connection
  api.authentication.setUserByConnection(
    connectionName,
    {
      // only the user_id in the connection is needed, as we are not
      // creating nor updating the user
      user_id: user.sub,
    },
    {
      creationBehavior: "none",
      updateBehavior: "none",
    },
  );
};

/**
 * Exchange the refresh token and load the user profile from the legacy IdP
 * @param {string} refreshToken
 * @param {string} clientSecret
 * @returns {Promise<{ isValid: boolean, user?: object }>} If the refresh token was exchanged successfully, returns the user profile
 */
async function getUserProfile(refreshToken, clientSecret) {
  const { isValid, accessToken } = await refreshAccessToken(
    refreshToken,
    clientSecret,
  );
  if (!isValid) {
    return { isValid: false };
  }

  const controller = new AbortController();
  setTimeout(() => controller.abort(), fetchTimeout);

  /** @type {any} */
  const response = await fetch(userInfoEndpoint, {
    method: "GET",
    headers: {
      Authorization: `Bearer ${accessToken}`,
      "Content-Type": "application/json",
    },
  });

  if (!response.ok) {
    console.log(`Failed to fetch user info. Status: ${response.status}`);
    throw new Error("Error fetching user info");
  }

  const userProfile = await response.json();

  return { isValid: true, user: userProfile };
}

/**
 * Use the Refresh Token with the legacy IdP to validate it and get an access token
 * @param {string} refreshToken
 * @param {string} clientSecret
 * @returns {Promise<{ isValid: boolean, accessToken?: string }>} If the refresh token was exchanged successfully, returns the access token
 */
async function refreshAccessToken(refreshToken, clientSecret) {
  const controller = new AbortController();
  setTimeout(() => controller.abort(), fetchTimeout);

  /** @type {any} */
  let response;

  try {
    response = await fetch(tokenEndpoint, {
      method: "POST",
      headers: {
        "Content-Type": "application/x-www-form-urlencoded",
      },
      body: new URLSearchParams({
        grant_type: "refresh_token",
        refresh_token: refreshToken,
        client_id: clientId,
        client_secret: clientSecret,
      }).toString(),
    });
  } catch (error) {
    console.error("Error refreshing token");
    throw error;
  }

  if (!response.ok) {
    const errorBody = await response.json();
    console.error("Error refreshing token:", errorBody.error);

    // If we receive an error indicating the refresh token is invalid (for example, an invalid_grant error),
    // then we should explicitly indicate an invalid token using api.access.rejectInvalidSubjectToken
    // to prevent against brute force attacks on the refresh token by activating Suspicious IP Throttling.
    // For other errors which indicate a generic error making the request to the IdP, we should throw
    // an error to indicate a transient failure.
    if (errorBody.error === "invalid_grant") {
      return { isValid: false };
    } else {
      throw new Error("Error refreshing token");
    }
  }

  // Parse the response, in the form { access_token: "...", expires_in: ..., }
  const data = await response.json();
  console.log("Successfully exchanged refresh token");
  return { isValid: true, accessToken: data.access_token };
}

Was this helpful?

/

Limitations

This is an Early Access feature and thus comes with some limitations and incompatibility with other Auth0 features.

The following features are not supported (or will not work properly) with Custom Token Exchange EA:

  • Organizations

  • MFA: api.authentication.challengeWith() and api.multifactor.enable() commands in Post Login actions are not yet supported for Custom Token Exchange and will result in the transaction failing with a non-recoverable error. Similarly, transactions will also fail when MFA is configured as a tenant policy.

  • Custom DB Connections

  • Specific impersonation support (e.g. actor token and actor claim)

  • Third-Party and Non-OIDC Conformant Clients

Rate Limits

Custom Token Exchange requests to the /oauth/token endpoint are rate limited at 10% of the global Authentication API rate limit for the applicable performance tier.

Performance Tier Global Authentication API limit (RPS) Custom Token Exchange limit (RPS)
Enterprise 100 10
Private Cloud Basic (1x) 100 10
Private Cloud Performance (5x) 500 50
Private Cloud Performance (15x) 1500 150
Private Cloud Performance (30x) 3000 300
Private Cloud Performance (60x) 6000 600
Private Cloud Performance (100x) 10000 1000

Read requests on api/v2/token-exchange-profiles endpoints are also rate limited as follows:

Performance Tier Custom Token Exchange limit (RPS) Custom Token Exchange limit (RPM)
Enterprise 20 200
Private Cloud Basic (1x) 20 200
Private Cloud Performance (5x) 100 300
Private Cloud Performance (15x) 300 3000
Private Cloud Performance (30x) 600 6000
Private Cloud Performance (60x) 1200 12000
Private Cloud Performance (100x) 2000 20000

Entity Limits

A maximum of 100 Custom Token Exchange profiles can be created per tenant.

The total number of Actions is also limited depending on your Auth0 plan. To learn more, read Auth0's Pricing Page.

Troubleshoot

You may receive an invalid_request error with a consent_required error description when calling the /oauth/token endpoint.

To resolve this issue, enable the Allow Skipping User Consent option for your API in the Auth0 Dashboard.