Skip to main content

Availability varies by Auth0 plan

Both your specific login implementation and your Auth0 plan or custom agreement affect whether this feature is available. To learn more, read Pricing.
You can link user accounts through a variety of methods:
  • Action with external linking application
  • Auth0
  • Auth0.js library

Action with external linking application

You can use an Action along with an external linking application to link user accounts with the Management API.
Auth0 does not automatically change to the correct primary user after Account Linking, so it must be changed within the Actions code upon successful Account Linking.Every manual account link should prompt the user to enter credentials. Your tenant should request authentication for both accounts before linking occurs to avoid allowing malicious actors to access legitimate user accounts.
The following steps illustrate an example implementation:
  1. Action identifies the potential user accounts to link (if they exist).
  2. Action redirects the user to an external linking application with a token payload that contains candidate user identities:
    {
      "current_identity": {
        "user_id": event.user.user_id,
        "provider": event.connection.strategy,
        "connection": event.connection.name
      },
      "candidate_identities": [
        {
          "user_id": USER_ID_1,
          "provider": PROVIDER_1,
          "connection": CONNECTION_1
        },
        {
          "user_id": USER_ID_2,
          "provider": PROVIDER_2,
          "connection": CONNECTION_2
        },
        ...
      ]
    }
    
  3. External linking application prompts the user to authenticate using the credentials for the account they wish to link.
  4. External linking application redirects the user back to the Action with a token payload that contains the primary and secondary user identities:
    {
      "primary_identity": {
        "user_id": PRIMARY_USER_ID,
        "provider": PRIMARY_PROVIDER_STRATEGY,
        "connection": PRIMARY_CONNECTION_NAME,
      },
      "secondary_identity": {
        "user_id": SECONDARY_USER_ID,
        "provider": SECONDARY_PROVIDER_STRATEGY,
        "connection": SECONDARY_CONNECTION_NAME,
      }
    }
    
  5. Action validates the authenticity and contents of the token.
  6. Action calls the Management API to link the accounts based on the results from the external linking application.
  7. Action switches to the primary user if it doesn’t match the event.user.user_id.

Example: Account linking Action

const { ManagementClient, AuthenticationClient } = require('auth0');

/**
 * Auth0 Account Linking Action - Production Version
 *
 * Required dependency: auth0@5.3.1
 *
 * This Action detects users with duplicate accounts (same verified email)
 * and redirects them to an external service to manage account linking.
 */

const ACCOUNT_LINKING_TIMESTAMP_KEY = 'account_linking_timestamp';
const TTL_LEEWAY_FACTOR = 0.2;
const PROPERTIES_TO_COMPLETE = ['given_name', 'family_name', 'name'];

/**
 * Get Management API access token with caching
 */
const getManagementAccessToken = async (event, api) => {
  const cacheKey = `mgmt-api-token-${event.secrets.MANAGEMENT_API_CLIENT_ID}`;
  const cached = api.cache.get(cacheKey);

  if (cached && cached.value) {
    return cached.value;
  }

  const auth = new AuthenticationClient({
    domain: event.secrets.MANAGEMENT_API_DOMAIN,
    clientId: event.secrets.MANAGEMENT_API_CLIENT_ID,
    clientSecret: event.secrets.MANAGEMENT_API_CLIENT_SECRET
  });

  const response = await auth.oauth.clientCredentialsGrant({
    audience: `https://${event.secrets.MANAGEMENT_API_DOMAIN}/api/v2/`
  });

  
  const accessToken = response.access_token || response.data?.access_token;
  const expiresIn = response.expires_in || response.data?.expires_in;

  if (accessToken && typeof accessToken === 'string') {
    api.cache.set(cacheKey, accessToken, {
      ttl: expiresIn - expiresIn * TTL_LEEWAY_FACTOR
    });
  }

  return accessToken;
};

/**
 * Get users with the same verified email address
 */
const getUsersWithSameEmail = async (event, api) => {
  const accessToken = await getManagementAccessToken(event, api);
  const management = new ManagementClient({
    domain: event.secrets.MANAGEMENT_API_DOMAIN,
    token: accessToken
  });

  
  const users = await management.users.listUsersByEmail({
    email: event.user.email
  });

  return users;
};

/**
 * Filter and map candidate identities with verified email
 */
const getCandidateIdentitiesWithVerifiedEmail = (event, candidateUsers) => {
  return candidateUsers
    .filter((user) => user.user_id !== event.user.user_id && user.email_verified === true)
    .filter((user) => user.identities && user.identities.length > 0)
    .map((user) => ({
      user_id: user.user_id,
      provider: user.identities[0].provider,
      connection: user.identities[0].connection
    }));
};

/**
 * Link accounts using Management API
 * Links secondary identity TO primary identity
 */
const linkAccounts = async (event, primaryIdentity, secondaryIdentity) => {
  const accessToken = await getManagementAccessToken(event, { cache: { get: () => null, set: () => {} } });

  // Extract the ID part after the | for the API
  const idParts = secondaryIdentity.user_id.split('|');
  const userId = idParts.length > 1 ? idParts[1] : secondaryIdentity.user_id;

  const url = `https://${event.secrets.MANAGEMENT_API_DOMAIN}/api/v2/users/${encodeURIComponent(primaryIdentity.user_id)}/identities`;

  const body = {
    provider: secondaryIdentity.provider,
    user_id: userId
  };

  const response = await fetch(url, {
    method: 'POST',
    headers: {
      'Authorization': `Bearer ${accessToken}`,
      'Content-Type': 'application/json'
    },
    body: JSON.stringify(body)
  });

  if (!response.ok) {
    const errorText = await response.text();
    throw new Error(`Link API error: ${response.status} - ${errorText}`);
  }

  return await response.json();
};

/**
 * Complete missing profile properties from linked identities
 */
const completeProperties = (event, api) => {
  for (const property of PROPERTIES_TO_COMPLETE) {
    if (!event.user[property]) {
      for (const identity of event.user.identities) {
        if (identity.profileData && identity.profileData[property]) {
          api.idToken.setCustomClaim(property, identity.profileData[property]);
          break;
        }
      }
    }
  }
};

/**
 * onExecutePostLogin - Detect duplicate accounts and redirect to linking service
 */
exports.onExecutePostLogin = async (event, api) => {
  // Validate configuration
  if (
    !event.secrets.MANAGEMENT_API_DOMAIN ||
    !event.secrets.MANAGEMENT_API_CLIENT_ID ||
    !event.secrets.MANAGEMENT_API_CLIENT_SECRET ||
    !event.secrets.SESSION_TOKEN_SHARED_SECRET ||
    !event.secrets.ACCOUNT_LINKING_SERVICE_URL
  ) {
    console.log('Missing required configuration - skipping account linking');
    return;
  }

// We won't process users for account linking until they have verified their email address.
  // We might consider rejecting logins here or redirecting users to an external tool to
  // remind the user to confirm their email address before proceeding.
  //
  // In this example, we simply won't process users unless their email is verified.
  if (!event.user.email_verified) {
    return;
  }

  // Skip if already processed
  if (event.user.app_metadata && event.user.app_metadata[ACCOUNT_LINKING_TIMESTAMP_KEY]) {
    completeProperties(event, api);
    return;
  }

  try {
    // Find users with same email
    const candidateUsers = await getUsersWithSameEmail(event, api);

    if (!Array.isArray(candidateUsers) || candidateUsers.length === 0) {
      return;
    }

    // Filter for verified emails
    const candidateIdentities = getCandidateIdentitiesWithVerifiedEmail(event, candidateUsers);

    if (candidateIdentities.length === 0) {
      return;
    }

    // Create session token
    const sessionToken = api.redirect.encodeToken({
      payload: {
        current_identity: {
          user_id: event.user.user_id,
          provider: event.connection.strategy,
          connection: event.connection.name
        },
        candidate_identities: candidateIdentities,
        email: event.user.email,
        continue_url: `https://${event.request.hostname}/continue`
      },
      secret: event.secrets.SESSION_TOKEN_SHARED_SECRET,
      expiresInSeconds: 120
    });

    // Redirect to linking service
    api.redirect.sendUserTo(event.secrets.ACCOUNT_LINKING_SERVICE_URL, {
      query: {
        session_token: sessionToken
      }
    });

  } catch (err) {
    console.error('Account linking error:', err.message);
    // Don't block login on error
  }
};

/**
 * onContinuePostLogin - Process user's linking decision
 */
exports.onContinuePostLogin = async (event, api) => {
  try {
    // Validate response token
    const { primary_identity: primaryIdentity, secondary_identity: secondaryIdentity } = api.redirect.validateToken({
      secret: event.secrets.SESSION_TOKEN_SHARED_SECRET,
      tokenParameterName: 'session_token'
    });

    if (!primaryIdentity || !secondaryIdentity) {
      // User cancelled - continue without linking
      return;
    }

    const currentUserId = event.user.user_id;

    // CRITICAL: Switch to primary user BEFORE linking
    // This prevents "Unable to construct login user" error
    if (primaryIdentity.user_id !== currentUserId) {
      api.authentication.setPrimaryUser(primaryIdentity.user_id);
    }

    // Link the secondary account
    const linkedIdentities = await linkAccounts(event, primaryIdentity, secondaryIdentity);

    if (linkedIdentities && linkedIdentities.length > 0) {
      // Mark as processed
      api.user.setAppMetadata(ACCOUNT_LINKING_TIMESTAMP_KEY, Date.now());
      completeProperties(event, api);
    } else {
      api.access.deny('Account linking failed');
    }
  } catch (err) {
    console.error('onContinuePostLogin error:', err.message);
    api.access.deny('Account linking error: ' + err.message);
  }
};

Management API

You can use the Management API Link a user account endpoint in two ways:
  • User-initiated client-side account linking using with the update:current_user_identities scope.
  • Server-side account linking using access tokens with the update:users scope.

User-initiated client-side account linking

For user-initiated client-side account linking, you need an access token that contains the following items in the payload:
  • update:current_user_identites scope
  • user_id of the primary account as part of the URL
  • of the secondary account that is signed with RS256 and includes an aud claim identifying the client that matches the value of the requesting access token’s azp claim.
An access token that contains the update:current_user_identities scope can only be used to update the information of the currently logged-in user. Therefore, this method is suitable for scenarios where the user initiates the linking process.

Server-side account linking

For server-side account linking, you need an access token that contains the following items in the payload:
  • update:users scope
  • user_id of the primary account as part of the URL
  • user_id of the secondary account
  • ID token of the secondary account that is signed with RS256 and includes an aud claim identifying the client that matches the value of the requesting access token’s azp claim.
Access tokens that contain the update:users scope can be used to update the information of any user. Therefore, this method is intended for use in server-side code only. The secondary user account’s user_id and provider can be deduced by its unique identifier. For example, for the identifier google-oauth2|108091299999329986433:
  • provider is google-oauth2
  • user_id is 108091299999329986433
Alternatively, you can you can send the secondary account’s ID token instead of the provider and user_id:

Auth0.js library

You can use the Auth0.js library to perform client-side account linking. Read Auth0.js v9 Reference > User management to learn more.

Learn more