Multi step not passing data correctly

Auth0 Actions: User Metadata Not Persisting Across Multi-Step Post-Login Flows

Overview

We are implementing a multi-step post-login wizard using Auth0 Actions and custom forms. The goal is to collect and validate user profile data (email, username, names, phone) in a sequence of steps, persisting the data between each step and ultimately issuing a JWT with a complete user profile.

The data is not persisting correctly across the steps, leading to incomplete user profiles in the final JWT. This document outlines the current implementation, the problem we are facing, and the steps we have taken to resolve it.

The implementation consists of five Actions, each responsible for a specific step in the user profile collection process. The Actions are triggered sequentially after the user logs in, and they interact with external APIs to validate the data.

The actions currently contain a lot of logging to help debug the issue, and we are using api.user.setUserMetadata() to store the collected data in user_metadata after each step which will obviously be removed.

The actions use the universal form rendering API to display forms to the user, and they handle the form submissions to validate and store the data.

Request for Help

  • Is there a supported way to persist data between steps in a multi-step Auth0 Action flow, given that user_metadata is not immediately available in the same login session?
  • Is there a way to add hidden or read-only fields to Auth0 Action forms to carry data between steps?
  • What is the recommended pattern for multi-step data collection in Auth0 Actions, given these limitations?

Any guidance or workarounds would be appreciated as would any best practices for structuring these flows given what we are trying to do.

The 5 Flows

  1. post_login_wiz1_checkEmail.js
  • Validates the user’s email address via an external API.
  • Sets the email in user_metadata if valid.
  1. post_login_wiz2_checkUserName.js
  • Collects and validates username, given_name, and family_name via a form and external API.
  • Sets these fields in user_metadata.
  1. post_login_wiz3_checkPhone.js
  • Collects and validates the user’s phone number via a form and external API.
  • Sets phone_number in user_metadata.
  1. post_login_wiz4_checkRouting.js
  • Determines which step is next based on missing fields in user_metadata.
  • Routes the user to the appropriate form.
  1. post_login_wiz5_markComplete.js
  • Marks the profile as complete in user_metadata and finalizes the flow.

What We Are Trying to Do

  • Guide the user through a series of forms to collect all required profile data.
  • Validate each field using external APIs such as unique username within our system, unique phone etc.
  • Persist all collected data between steps using user_metadata.
  • Ensure the final JWT contains all required fields.

The Problem

Observed Behavior:

  • After each step, we use api.user.setUserMetadata() to store the collected data.
  • The logs show that the correct data is being set at each step.
  • However, when the next step runs, the previously set data is missing from event.user.user_metadata.
  • This causes subsequent forms to re-request data already entered, and the final JWT is incomplete.

Example Log Excerpt:

12:51:29: [buildAndSetUserMetadata] Setting user_metadata: {"username":"vvvvvuser","given_name":"vvvfirst","family_name":"vvvvlast"}
12:51:29: [post_login_wiz3_checkPhone] user_metadata at entry: object {}
12:51:29: [post_login_wiz3_checkPhone] Missing phone_number, rendering phone form
  • The username and names were set, but are missing when the phone step starts.

What We’ve Tried

  • Merging all available data from the form and event in each handler.
  • Rearranging the order of steps to ensure data is set before it is needed.
  • Using api.user.setUserMetadata() to set data at each step, but it seems not to persist correctly across steps.

The flows

Here is the code for the post_login_wiz1_checkEmail.js Action, which validates the email and sets it in user_metadata:

const axios = require("axios");

// Standardized helper for building and setting user metadata
function buildAndSetUserMetadata(api, event, formFields = {}) {
  const meta = {
    username: formFields.username || event.user.username || event.user.user_metadata?.username,
    given_name: formFields.given_name || event.user.given_name || event.user.user_metadata?.given_name,
    family_name: formFields.family_name || event.user.family_name || event.user.user_metadata?.family_name,
    phone_number: formFields.phone_number || event.user.phone_number || event.user.user_metadata?.phone_number,
    email: formFields.email || event.user.email || event.user.user_metadata?.email
  };
  const filtered = {};
  for (const [key, value] of Object.entries(meta)) {
    if (value !== undefined && value !== null && value !== '' && value !== 'undefined' && value !== 'null') {
      filtered[key] = value;
    }
  }
  console.log('[buildAndSetUserMetadata] Setting user_metadata:', JSON.stringify(filtered));
  api.user.setUserMetadata(filtered);
}

async function callValidationApi(secrets, endpointSecretKey, payload) {
  const { ENVIRONMENT, API_HOSTNAME_TEMPLATE } = secrets;
  const endpointPathAndKey = secrets[endpointSecretKey];

  if (!ENVIRONMENT || !API_HOSTNAME_TEMPLATE || !endpointPathAndKey) {
    throw new Error(`Configuration error: A required secret is missing for ${endpointSecretKey}.`);
  }

  // Construct the full URL from the secrets
  const baseUrl = API_HOSTNAME_TEMPLATE.replace('${environment}', ENVIRONMENT);

  console.log(baseUrl);
  console.log(ENVIRONMENT);
  const fullUrl = `https://${baseUrl}${endpointPathAndKey}`;

  console.log(`Calling validation endpoint: ${fullUrl}`);
  try {
    const response = await axios.post(fullUrl, payload);
    return response.data;
  } catch (error) {
    console.error(`API call to ${fullUrl} failed: ${error}`);
    throw new Error('An external API call failed.');
  }
}

exports.onExecutePostLogin = async (event, api) => {
  // Only care about social / passwordless and FIRST login
  console.log('[post_login_wiz1_checkEmail] user_metadata at entry:', typeof event.user.user_metadata, JSON.stringify(event.user.user_metadata));
  console.log(`[post_login_wiz1_checkEmail] event data follows`);
  console.log(JSON.stringify(event, null, 2));

  if (event.stats.logins_count !== 1) {
    console.log(`[post_login_wiz1_checkEmail] Only 1 login so can ignore as only care about social / passwordless and FIRST login`);
    return;
  }

  if (event.connection.strategy === 'auth0') {
    console.log('[post_login_wiz1_checkEmail] is auth0 database so email already captured during signup');
    // Optionally, set email in metadata for consistency
    buildAndSetUserMetadata(api, event, { email: event.user.email });
    return;
  }  // database

  try {
    const { email } = event.user;
    const socialAccountUsed = event.connection.strategy === 'auth0' ? "" : event.connection.name;

    const result = await callValidationApi(
      event.secrets,
      'VALIDATE_EMAIL_ENDPOINT',
      { EmailAddress: email, SocialAccountUsed: socialAccountUsed }
    );

    console.log(`[post_login_wiz1_checkEmail] validate_email result: ${JSON.stringify(result)}`);

    if (result.code !== 'EV_AVAIL') {      
      const userMessage = result.userMessage;
      return api.access.deny('email_in_use', userMessage);
    } else {
      console.log('[post_login_wiz1_checkEmail] Email available');
      // Set email in metadata for consistency
      buildAndSetUserMetadata(api, event, { email });
    }
  } catch (error) {
    console.error(`[post_login_wiz1_checkEmail] Error in 'Validate Email Address' action: ${error}`);
    return api.access.deny('validation_api_error', 'An unexpected error occurred. Please try again.');
  }
};

Here is the code for the post_login_wiz2_checkUserName.js Action, which collects and validates the username and names:

const axios = require('axios');

const isMissing = v => v == null || v === '' || v === 'undefined' || v === 'null';

const buildUrl = (secrets, endpointKey) => {
  const { ENVIRONMENT, API_HOSTNAME_TEMPLATE } = secrets;
  const pathAndKey = secrets[endpointKey];
  if (isMissing(ENVIRONMENT) || isMissing(API_HOSTNAME_TEMPLATE) || isMissing(pathAndKey)) {
    throw new Error(`Missing secrets for ${endpointKey}`);
  }
  const host = API_HOSTNAME_TEMPLATE.replace('${environment}', ENVIRONMENT);
  return `https://${host}${pathAndKey}`;
};

const callValidationApi = async (secrets, endpointKey, payload) => {
  const url = buildUrl(secrets, endpointKey);
  console.log(`[post_login_wiz2_checkUserName] → POST ${url} with payload: ${JSON.stringify(payload)}`);
  const { data } = await axios.post(url, payload);
  return data;
};

const usernameFormId = 'ap_ABCDE';
const apiErrorFormID = 'ap_FGHIJ';

// The form fields as defined in signup_username.json
const requiredFields = [
  { key: 'username', error: 'Please choose a username.' },
  { key: 'given_name', error: 'Please enter your first name.' },
  { key: 'family_name', error: 'Please enter your last name.' }
];

const hasAllFields = meta =>
  requiredFields.every(f => !isMissing(meta?.[f.key]));

const validateFields = form => {
  for (const { key, error } of requiredFields) {
    if (isMissing(form[key]) || isMissing(form[key].trim())) {
      return { valid: false, key, error };
    }
  }
  return { valid: true };
};

// Standardized helper for building and setting user metadata
function buildAndSetUserMetadata(api, event, formFields = {}) {
  // Combine data from form fields and event.user
  const meta = {
    username: formFields.username || event.user.username,
    given_name: formFields.given_name || event.user.given_name,
    family_name: formFields.family_name || event.user.family_name,
    phone_number: formFields.phone_number || event.user.phone_number
  };
  // Only include defined, non-empty fields
  const filtered = {};
  for (const [key, value] of Object.entries(meta)) {
    if (value !== undefined && value !== null && value !== '' && value !== 'undefined' && value !== 'null') {
      filtered[key] = value;
    }
  }
  console.log('[buildAndSetUserMetadata] Setting user_metadata:', JSON.stringify(filtered));
  api.user.setUserMetadata(filtered);
}

exports.onExecutePostLogin = async (event, api) => {
  const userMeta = event.user.user_metadata || {};
  console.log(`[post_login_wiz2_checkUserName] onExecutePostLogin: user_metadata = ${JSON.stringify(userMeta)}`);
  // Only render form if required fields are missing
  if (!hasAllFields(userMeta)) {
    console.log('[post_login_wiz2_checkUserName] Missing required fields, rendering username form');
    return api.prompt.render(usernameFormId);
  }
  console.log('[post_login_wiz2_checkUserName] All required fields present, skipping username form');
};

exports.onContinuePostLogin = async (event, api) => {
  console.log('[post_login_wiz2_checkUserName] user_metadata at entry:', typeof event.user.user_metadata, JSON.stringify(event.user.user_metadata));

  const form = event.prompt?.fields || {};
  console.log(`[post_login_wiz2_checkUserName] onContinuePostLogin: form = ${JSON.stringify(form)}, user_metadata = ${JSON.stringify(event.user.user_metadata)}`);
  console.log('[post_login_wiz2_checkUserName] onContinuePostLogin START', JSON.stringify(event.user.user_metadata));

  // Validate required fields
  const validation = validateFields(form);
  if (!validation.valid) {
    console.log(`[post_login_wiz2_checkUserName] Validation failed for ${validation.key}: ${validation.error}`);
    return api.prompt.render(usernameFormId, { errors: { [validation.key]: validation.error } });
  }

  // Only call the API if the username is different from what's already in metadata
  const userMeta = event.user.user_metadata || {};
  const usernameChanged = form.username !== userMeta.username;
  if (usernameChanged) {
    try {
      const res = await callValidationApi(
        event.secrets,
        'VALIDATE_USERNAME_ENDPOINT',
        { Username: form.username }
      );
      console.log(`[post_login_wiz2_checkUserName] Username validation API result: ${JSON.stringify(res)}`);

      if (res.code !== 'UN_AVAIL') {
        console.log(`[post_login_wiz2_checkUserName] Username not available: ${res.userMessage || 'Username already taken'}`);
        return api.prompt.render(usernameFormId, {
          errors: { username: res.userMessage || 'Username already taken' }
        });
      }
    } catch (error) {
      console.error(`[post_login_wiz2_checkUserName] Username validation API call failed: ${error}`);
      return api.prompt.render(apiErrorFormID);
    }
  }

  // Set all metadata fields at once, combining across steps
  buildAndSetUserMetadata(api, event, {
    username: form.username,
    given_name: form.given_name,
    family_name: form.family_name
  });

  console.log(`[post_login_wiz2_checkUserName] Username and names accepted and saved: ${JSON.stringify({
    username: form.username,
    given_name: form.given_name,
    family_name: form.family_name
  })}`);
  // Do not render the form or return anything else; let the wizard proceed
};

'Here is the code for the post_login_wiz3_checkPhone.js Action, which collects and validates the phone number:

const axios = require("axios");
const isMissing = v => !v || v === "undefined" || v === "null";

const phoneFormId = 'ap_LMNOP';
const apiErrorFormID = 'ap_FGHIJ';

// Standardized helper for building and setting user metadata
function buildAndSetUserMetadata(api, event, formFields = {}) {
  const meta = {
    username: formFields.username || event.user.username,
    given_name: formFields.given_name || event.user.given_name,
    family_name: formFields.family_name || event.user.family_name,
    phone_number: formFields.phone_number || event.user.phone_number
  };
  const filtered = {};
  for (const [key, value] of Object.entries(meta)) {
    if (value !== undefined && value !== null && value !== '' && value !== 'undefined' && value !== 'null') {
      filtered[key] = value;
    }
  }
  console.log('[buildAndSetUserMetadata] Setting user_metadata:', JSON.stringify(filtered));
  api.user.setUserMetadata(filtered);
}

async function callValidationApi(secrets, endpointSecretKey, payload) {
  const { ENVIRONMENT, API_HOSTNAME_TEMPLATE } = secrets;
  const endpointPathAndKey = secrets[endpointSecretKey];

  if (!ENVIRONMENT || !API_HOSTNAME_TEMPLATE || !endpointPathAndKey) {
    throw new Error(`Configuration error: A required secret is missing for ${endpointSecretKey}.`);
  }

  const baseUrl = API_HOSTNAME_TEMPLATE.replace('${environment}', ENVIRONMENT);
  const fullUrl = `https://${baseUrl}${endpointPathAndKey}`;

  console.log(`Calling validation endpoint: ${fullUrl}`);
  try {
    const response = await axios.post(fullUrl, payload);
    return response.data;
  } catch (error) {
    console.error(`API call to ${fullUrl} failed: ${error}`);
    throw new Error('An external API call failed.');
  }
}

exports.onExecutePostLogin = async (event, api) => {
  console.log('[post_login_wiz3_checkPhone] user_metadata at entry:', typeof event.user.user_metadata, JSON.stringify(event.user.user_metadata));
  const phoneNumber = event.user.phone_number || event.user.user_metadata?.phone_number;
  if (!phoneNumber) {
    console.log('[post_login_wiz3_checkPhone] Missing phone_number, rendering phone form');
    return api.prompt.render(phoneFormId);
  }
  console.log('[post_login_wiz3_checkPhone] phone_number present, skipping phone form');
};

exports.onContinuePostLogin = async (event, api) => {
  console.log('[post_login_wiz3_checkPhone] onContinuePostLogin called');
  console.log('[post_login_wiz3_checkPhone] event.prompt:', JSON.stringify(event.prompt));
  const form = event.prompt?.fields || {};

  let phoneNumber = form.phone_number || form.telephone;
  if (typeof phoneNumber === 'object' && phoneNumber.number) {
    phoneNumber = phoneNumber.number;
  }

  if (!phoneNumber) {
    console.log('[post_login_wiz3_checkPhone] No phone number found in form, re-rendering form with error');
    return api.prompt.render(phoneFormId, { errors: { phone_number: 'Please enter your phone number.' } });
  }

  try {
    const socialAccountUsed = event.connection.strategy === 'auth0' ? "" : event.connection.name;
    const result = await callValidationApi(
      event.secrets,
      'VALIDATE_PHONE_ENDPOINT',
      { PhoneNumber: phoneNumber, EmailAddress: event.user.email, SocialAccountUsed: socialAccountUsed }
    );
    console.log(`[post_login_wiz3_checkPhone] VALIDATE_PHONE_ENDPOINT result:`, result);

    if (result.code !== 'PN_AVAIL') {
      console.log('[post_login_wiz3_checkPhone] Phone not available, re-rendering form with error');
      return api.prompt.render(phoneFormId, {
        errors: {
          phone_number: result.userMessage || 'Phone number not available'
        }
      });
    }

    // Use the standardized helper to set all metadata fields, combining across steps
    buildAndSetUserMetadata(api, event, { phone_number: phoneNumber });

    console.log(`[post_login_wiz3_checkPhone] Phone validated and saved: ${phoneNumber}`);
  } catch (error) {
    console.error(`[post_login_wiz3_checkPhone] Error in 'Validate Phone' action: ${error}`);
    return api.prompt.render(apiErrorFormID);
  }
};

Here is the code for the post_login_wiz4_checkRouting.js Action, which checks the user metadata and routes to the appropriate form:

const usernameFormId = 'ap_ABCDE';
const phoneFormId = 'ap_LMNOP';
const doneFormId = 'ap_QRSTU';

// Standardized helper for building and setting user metadata
function buildAndSetUserMetadata(api, event, formFields = {}) {
  const meta = {
    username: formFields.username || event.user.username || event.user.user_metadata?.username,
    given_name: formFields.given_name || event.user.given_name || event.user.user_metadata?.given_name,
    family_name: formFields.family_name || event.user.family_name || event.user.user_metadata?.family_name,
    phone_number: formFields.phone_number || event.user.phone_number || event.user.user_metadata?.phone_number,
    email: formFields.email || event.user.email || event.user.user_metadata?.email
  };
  const filtered = {};
  for (const [key, value] of Object.entries(meta)) {
    if (value !== undefined && value !== null && value !== '' && value !== 'undefined' && value !== 'null') {
      filtered[key] = value;
    }
  }
  console.log('[buildAndSetUserMetadata] Setting user_metadata:', JSON.stringify(filtered));
  api.user.setUserMetadata(filtered);
}

function isMissing(v) {
  return v == null || v === '' || v === 'undefined' || v === 'null';
}

exports.onExecutePostLogin = async (event, api) => {
  console.log('[post_login_wiz4_checkRouting] user_metadata at entry:', typeof event.user.user_metadata, JSON.stringify(event.user.user_metadata));

  const userMeta = event.user.user_metadata || {};
  console.log('[post_login_wiz4_checkRouting] user_metadata at entry:', JSON.stringify(event.user.user_metadata));

  // Check for missing username fields
  const needsUsername =
    isMissing(userMeta.username) ||
    isMissing(userMeta.given_name) ||
    isMissing(userMeta.family_name);

  // Check for missing phone (standardize to phone_number)
  const needsPhone = isMissing(userMeta.phone_number);

  // Check for profile completion
  const isProfileComplete = userMeta.profile_complete === true;

  if (needsUsername) {
    console.log('[post_login_wiz4_checkRouting] needsUsername = true, rendering username form');
    return api.prompt.render(usernameFormId);
  }

  if (needsPhone) {
    console.log('[post_login_wiz4_checkRouting] needsPhone = true, rendering phone form');
    return api.prompt.render(phoneFormId);
  }

  if (!isProfileComplete) {
    console.log('[post_login_wiz4_checkRouting] All data present, rendering done form');
    return api.prompt.render(doneFormId);
  }

  console.log('[post_login_wiz4_checkRouting] All required data present and profile marked complete, no form needed.');
  // No form needed, let login proceed
};

exports.onContinuePostLogin = async (event, api) => {
  console.log('[post_login_wiz4_checkRouting] user_metadata at entry:', typeof event.user.user_metadata, JSON.stringify(event.user.user_metadata));
  // No action needed, but handler must be present
  console.log('[post_login_wiz4_checkRouting] onContinuePostLogin called, nothing to do.');
};
const axios = require("axios");
const isMissing = v => !v || v === "undefined" || v === "null";

const doneFormId = 'ap_QRSTU';
const apiErrorFormID = 'ap_FGHIJ';

// Standardized helper for building and setting user metadata
function buildAndSetUserMetadata(api, event, formFields = {}) {
  const meta = {
    username: formFields.username || event.user.username || event.user.user_metadata?.username,
    given_name: formFields.given_name || event.user.given_name || event.user.user_metadata?.given_name,
    family_name: formFields.family_name || event.user.family_name || event.user.user_metadata?.family_name,
    phone_number: formFields.phone_number || event.user.phone_number || event.user.user_metadata?.phone_number,
    email: formFields.email || event.user.email || event.user.user_metadata?.email,
    profile_complete: formFields.profile_complete || event.user.user_metadata?.profile_complete
  };
  const filtered = {};
  for (const [key, value] of Object.entries(meta)) {
    if (value !== undefined && value !== null && value !== '' && value !== 'undefined' && value !== 'null') {
      filtered[key] = value;
    }
  }
  console.log('[buildAndSetUserMetadata] Setting user_metadata:', JSON.stringify(filtered));
  api.user.setUserMetadata(filtered);
}

async function callValidationApi(secrets, endpointSecretKey, payload) {
  const { ENVIRONMENT, API_HOSTNAME_TEMPLATE } = secrets;
  const endpointPathAndKey = secrets[endpointSecretKey];

  if (!ENVIRONMENT || !API_HOSTNAME_TEMPLATE || !endpointPathAndKey) {
    throw new Error(`Configuration error: A required secret is missing for ${endpointSecretKey}.`);
  }

  const baseUrl = API_HOSTNAME_TEMPLATE.replace('${environment}', ENVIRONMENT);
  const fullUrl = `https://${baseUrl}${endpointPathAndKey}`;

  console.log(`Calling completion endpoint: ${fullUrl}`);
  try {
    const response = await axios.post(fullUrl, payload);
    return response.data;
  } catch (error) {
    console.error(`API call to ${fullUrl} failed: ${error}`);
    throw new Error('An external API call failed.');
  }
}

exports.onExecutePostLogin = async (event, api) => {
  console.log('[post_login_wiz5_markComplete] user_metadata at entry:', typeof event.user.user_metadata, JSON.stringify(event.user.user_metadata));
  console.log("in post_login_wiz5_markComplete - onExecute");

  // Check if we have all required data
  const user = event.user;
  const userMeta = user.user_metadata || {};
  const hasUsername = !isMissing(userMeta.username);
  const hasPhone = !isMissing(userMeta.phone_number);
  const isAlreadyComplete = !isMissing(userMeta.profile_complete);
  const openId = user && user.user_id;

  console.log('[post_login_wiz5_markComplete] onExecutePostLogin START', JSON.stringify(event.user.user_metadata));
  console.log(`Completion check: username=${hasUsername}, phone=${hasPhone}, complete=${isAlreadyComplete}`);

  if (hasUsername && hasPhone && !isAlreadyComplete) {
    try {
      console.log("All data collected, calling completion API...");

      // Call API to mark user as complete
      const result = await callValidationApi(
        event.secrets,
        'UPDATE_USER_ENDPOINT',
        { 
          UserId: user.user_id,
          Username: userMeta.username,
          Phone: userMeta.phone_number,
          Email: user.email,
          FirstName: userMeta.given_name || user.given_name,
          LastName: userMeta.family_name || user.family_name,
          OpenId: openId,
          LoginSource: event.connection.strategy 
        }
      );

      console.log(`UPDATE_USER_ENDPOINT result:`, result);

      if (result.code === 'PROFILE_COMPLETE' || result === 'OK') {
        // Mark profile as complete using standardized helper
        buildAndSetUserMetadata(api, event, { profile_complete: true });
        console.log('Profile marked as complete');
        // Show completion screen
        return api.prompt.render(doneFormId);
      } else {
        console.error('Profile completion failed:', result.userMessage);
        return api.prompt.render(apiErrorFormID);
      }
    } catch (error) {
      console.error(`Error in profile completion: ${error}`);
      return api.prompt.render(apiErrorFormID);
    }
  }

  console.log("Profile completion not needed at this time");
};

exports.onContinuePostLogin = async (event, api) => {
  console.log('[post_login_wiz5_markComplete] user_metadata at entry:', typeof event.user.user_metadata, JSON.stringify(event.user.user_metadata));
  console.log('[post_login_wiz5_markComplete] onContinuePostLogin START', JSON.stringify(event.user.user_metadata));
  console.log("in post_login_wiz5_markComplete - onContinue");
  console.log("Profile setup complete, continuing login");
};

The Form JSON

Here is the current usernam form step(signup_username.json):

{
  "version": "4.0.0",
  "form": {
    "name": "signup_username",
    "languages": {
      "primary": "en"
    },
    "nodes": [
      {
        "id": "step_LV5P",
        "type": "STEP",
        "coordinates": {
          "x": 500,
          "y": 0
        },
        "alias": "New step",
        "config": {
          "components": [
            {
              "id": "rich_text_XarD",
              "category": "BLOCK",
              "type": "RICH_TEXT",
              "config": {
                "content": "<h1>Please add your username and contact information</h1>"
              }
            },
            {
              "id": "username",
              "category": "FIELD",
              "type": "TEXT",
              "label": "Username",
              "required": true,
              "sensitive": false,
              "config": {
                "multiline": false,
                "placeholder": "Username",
                "min_length": 5,
                "max_length": 50
              }
            },
            {
              "id": "given_name",
              "category": "FIELD",
              "type": "TEXT",
              "label": "First name",
              "required": false,
              "sensitive": false,
              "config": {
                "multiline": false,
                "placeholder": "First name",
                "min_length": 2,
                "max_length": 50
              }
            },
            {
              "id": "family_name",
              "category": "FIELD",
              "type": "TEXT",
              "label": "Last name",
              "required": false,
              "sensitive": false,
              "config": {
                "multiline": false,
                "placeholder": "Last name",
                "min_length": 2,
                "max_length": 50
              }
            },
            {
              "id": "previous_button_PHvK",
              "category": "BLOCK",
              "type": "PREVIOUS_BUTTON",
              "config": {
                "text": "Back"
              }
            },
            {
              "id": "next_button_H3gh",
              "category": "BLOCK",
              "type": "NEXT_BUTTON",
              "config": {
                "text": "Continue"
              }
            }
          ],
          "next_node": "$ending"
        }
      }
    ],
    "start": {
      "next_node": "step_LV5P",
      "coordinates": {
        "x": 0,
        "y": 0
      }
    },
    "ending": {
      "resume_flow": true,
      "coordinates": {
        "x": 1250,
        "y": 0
      }
    }
  },
  "flows": {},
  "connections": {}
}

Here is the current phone step form (signup_phone.json):

{
  "version": "4.0.0",
  "form": {
    "name": "signup_phone",
    "languages": {
      "primary": "en"
    },
    "nodes": [
      {
        "id": "step_zbrj",
        "type": "STEP",
        "coordinates": {
          "x": 500,
          "y": 0
        },
        "alias": "New step",
        "config": {
          "components": [
            {
              "id": "rich_text_eI6E",
              "category": "BLOCK",
              "type": "RICH_TEXT",
              "config": {
                "content": "<h1>Please add your phone number</h1>"
              }
            },
            {
              "id": "telephone",
              "category": "FIELD",
              "type": "TEL",
              "label": "Phone",
              "required": true,
              "sensitive": false,
              "config": {
                "country_picker": true,
                "default_value": "+44",
                "strings": {
                  "filter_placeholder": "+44"
                }
              }
            },
            {
              "id": "previous_button_qX4J",
              "category": "BLOCK",
              "type": "PREVIOUS_BUTTON",
              "config": {
                "text": "Back"
              }
            },
            {
              "id": "next_button_rnFl",
              "category": "BLOCK",
              "type": "NEXT_BUTTON",
              "config": {
                "text": "Continue"
              }
            }
          ],
          "next_node": "$ending"
        }
      }
    ],
    "start": {
      "hidden_fields": [
        {
          "key": "username"
        },
        {
          "key": "given_name"
        },
        {
          "key": "family_name"
        }
      ],
      "next_node": "step_zbrj",
      "coordinates": {
        "x": 0,
        "y": 0
      }
    },
    "ending": {
      "resume_flow": true,
      "coordinates": {
        "x": 1250,
        "y": 0
      }
    }
  },
  "flows": {},
  "connections": {}
}

Hi @zebslc

Welcome to the Auth0 Community!

Thank you for posting your question. First of all, to answer your concern about metadata persisting the behaviour you observe is expected and by design.

Multiple setUserMetadata or setAppMetadata calls will be batched together into a single user profile update at the end of the trigger’s execution, even if they are made by different Actions.

  • Is there a supported way to persist data between steps in a multi-step Auth0 Action flow, given that user_metadata is not immediately available in the same login session?

The recommended way to store data between multi-step actions would be a caching mechanism. You can read about that here →

However, cache has certain limitations that you need to consider during implementation →

  • Is there a way to add hidden or read-only fields to Auth0 Action forms to carry data between steps?

Unfortunately, no, due to the Action limitation, however, I would encourage you to open a new thread in the Product Feedback category explaining your use case. If the thread becomes popular among other community members, our product team will evaluate the idea.

  • What is the recommended pattern for multi-step data collection in Auth0 Actions, given these limitations?

If that is possible, the recommended approach would be to reduce the number of actions to a single action; this way, you don’t need to worry about sharing the user information between action triggers.

Thanks
Dawid