Auth0 Rules and Hooks are in a read-only phase, which means you can no longer fix a bug in a Rule without first rewriting it as an Action. The dates people quote are also inconsistent: older posts and some docs pages still say November 18, 2026. On the current Auth0 Deprecations and Migrations page, Hooks reach end of life on April 30, 2027 and Rules on January 31, 2028. Both were deprecated on May 16, 2023 and went read-only on November 18, 2024.
This guide is the practical half of that announcement: a line-by-line mapping from Rule code to Action code, a cutover plan that does not break logins, and the failure modes we see when teams paste Rule code into an Action. It complements the Auth0 vs Keycloak comparison and the Keycloak vs Auth0 vs Okta decision guide; here the assumption is that you are staying on Auth0 and need the extensibility code moved.
We also covered this on video: Auth0 Actions vs Custom Rules: Migrating Legacy Extensibility Code.
What “Read-Only” Actually Means
Auth0 defines the read-only phase the same way on the deprecations page and in the Rules-to-Actions migration guide:
- Active Rules still run.
- You can enable or disable them and change their configuration values and secrets.
- You cannot edit their code in the Dashboard, the Management API, or tools built on it: the Auth0 CLI, the Deploy CLI and Terraform.
- Rules are not available in tenants created after October 16, 2023.
The practical consequences:
- CI/CD that deploys Rules will fail on code changes. A pipeline that pushes
rules/*.jsthrough the Deploy CLI breaks the first time that file’s content differs from what is in the tenant. Freeze those files, or remove them from the pipeline once the replacement Action is live. - Hotfixes need an Action. A one-line change to a claim name means building the Action, not editing the Rule.
- The runtime clock is separate from the product clock. The Node.js 18 extensibility runtime is deprecated as of November 10, 2026 and reaches end of life on May 10, 2027, and that applies to Rules and Hooks as well. Waiting for the 2028 Rules date does not avoid a runtime deadline.
Dates at a glance
| Item | Deprecated | Read-only | End of life |
|---|---|---|---|
| Rules | May 16, 2023 | November 18, 2024 | January 31, 2028 |
| Hooks | May 16, 2023 | November 18, 2024 | April 30, 2027 |
| Node.js 18 extensibility runtime | November 10, 2026 | n/a | May 10, 2027 |
Hooks are the nearer deadline, so inventory them first. If you only remember one thing, remember that Hooks cover more than login: credentials exchange, pre-user-registration, post-user-registration and post-change-password logic all need an Action equivalent.
Rules vs Actions: What Actually Changes
A Rule is a single function with a callback that runs inside a global pipeline. An Action is a handler bound to a specific trigger, with explicit objects for reading state (event) and changing it (api).
| Concern | Rule | Action |
|---|---|---|
| Signature | function (user, context, callback) | exports.onExecutePostLogin = async (event, api) => {} |
| Read the user | user.email | event.user.email |
| App metadata | may be undefined | event.user.app_metadata is never undefined |
| Client ID | context.clientID | event.client.client_id |
| Connection | context.connectionID | event.connection.id |
| Protocol | context.protocol | event.transaction.protocol |
| Tenant | context.tenant | event.tenant.id |
| Finish OK | callback(null, user, context) | return; |
| Deny | callback(new Error("msg")) | return api.access.deny("msg"); |
| Secrets | configuration.KEY | event.secrets.KEY (set per Action) |
| ID token claim | context.idToken[ns] = v | api.idToken.setCustomClaim(ns, v) |
| Access token claim | context.accessToken[ns] = v | api.accessToken.setCustomClaim(ns, v) |
| MFA | context.multifactor = {...} | api.multifactor.enable("any", {...}) |
| Update metadata | auth0.users.updateAppMetadata(...) | api.user.setAppMetadata(k, v) |
| SSO clients | context.sso.current_clients | event.session.clients |
Three of these rows cause most of the bugs.
1. No callback, and a different failure model
A Rule that falls off the end without calling the callback hangs the login. An Action that falls off the end simply succeeds. The reverse bug is more common during migration: leftover callback(...) lines throw, because callback is not defined in the Action scope.
2. No global auth0 object and no Management API token
Rules gave you an auth0 object with a pre-authorized Management API client. Actions do not. The migration guide is explicit: Actions are not provided with an access token for the Management API or the global auth0 object, and they cannot persist data across executions, so you cannot cache a token between logins.
For the common case (writing metadata), use the api.user.* setters. Auth0 batches several setter calls from multiple Actions into a single Management API call when the flow completes. For anything else, create a machine-to-machine application and pass its credentials through the Action’s secrets.
3. Secrets are per Action
configuration was one global bag shared by all Rules. Secrets in Actions belong to the Action that declares them. If three Rules read configuration.SLACK_WEBHOOK, you either add the secret to three Actions or collapse the shared logic into one.
Worked Example 1: Block Unverified Emails and Add Roles to Tokens
A very common Rule:
// Rule (legacy)
function addRolesAndEnforceVerification(user, context, callback) {
const namespace = 'https://example.com/';
if (!user.email_verified) {
return callback(new UnauthorizedError('Please verify your email before logging in.'));
}
const assignedRoles = (context.authorization || {}).roles || [];
context.idToken[namespace + 'roles'] = assignedRoles;
context.accessToken[namespace + 'roles'] = assignedRoles;
callback(null, user, context);
}
The Action equivalent:
// Action: Login / Post Login
exports.onExecutePostLogin = async (event, api) => {
const namespace = 'https://example.com/';
if (!event.user.email_verified) {
return api.access.deny('Please verify your email before logging in.');
}
const assignedRoles = (event.authorization || {}).roles || [];
api.idToken.setCustomClaim(namespace + 'roles', assignedRoles);
api.accessToken.setCustomClaim(namespace + 'roles', assignedRoles);
};
Notes:
UnauthorizedErrorwas a Rules-only global.api.access.deny(reason)replaces it and the reason is shown to the user.- Keep the namespaced claim names (
https://example.com/roles). Auth0 still ignores non-namespaced custom claims on tokens, so do not “simplify” them during migration. If downstream APIs validate the old claim names, changing them breaks those APIs. You can test the resulting tokens in the JWT Decoder before cutover. - Use
event.authorizationguards as shown; it can be absent when the user has no roles.
Worked Example 2: Rules That Call External APIs
A Rule that posts to a webhook on first login:
// Rule (legacy)
function notifyOnFirstLogin(user, context, callback) {
const request = require('[email protected]');
const { WEBHOOK_URL } = configuration;
if (context.stats.loginsCount > 1) {
return callback(null, user, context);
}
request.post({ url: WEBHOOK_URL, json: { email: user.email } }, (err) => {
if (err) console.log('webhook failed', err);
callback(null, user, context);
});
}
As an Action, drop the callback style and the pinned require string. Add the dependency version in the Dashboard under Add a Dependency instead, and require it at the top of the file:
// Action: Login / Post Login
const axios = require('axios'); // dependency added in the Dashboard, e.g. 1.x
exports.onExecutePostLogin = async (event, api) => {
if (event.stats.logins_count > 1) return;
try {
await axios.post(
event.secrets.WEBHOOK_URL,
{ email: event.user.email },
{ timeout: 2000 }
);
} catch (err) {
// Do not fail the login because a webhook is down
console.log('webhook failed', err.message);
}
};
Two practical points. First, the migration guide warns against calling external APIs on every login on the critical path: a slow dependency at peak traffic becomes failed logins. Set a short timeout and decide explicitly whether a failure should deny access (above, it does not). Second, request is deprecated upstream, so this is the cheapest moment to swap it for axios or the built-in fetch.
Worked Example 3: Redirects Are a Pause-and-Resume Flow
Rules set context.redirect = { url: ... } and handled the return in the same function by checking context.protocol === 'redirect-callback'. Actions split that into two handlers: onExecutePostLogin sends the user out, and onContinuePostLogin runs when the user returns to /continue.
exports.onExecutePostLogin = async (event, api) => {
if (event.user.app_metadata.terms_accepted) return;
api.redirect.sendUserTo('https://app.example.com/terms', {
query: { email: event.user.email }
});
};
exports.onContinuePostLogin = async (event, api) => {
// Runs after the user returns to https://{domain}/continue?state=...
api.user.setAppMetadata('terms_accepted', true);
};
Auth0 documents this separately under “Redirect with Actions”. If your Rules pass data to the external page, review how the state and signed session tokens are produced there rather than porting the Rule’s token code line for line.
Hooks: The Nearer Deadline
Hooks end first (April 30, 2027), and each hook extensibility point maps to an Action trigger:
| Hook type | Action trigger |
|---|---|
| Client credentials exchange | Machine to Machine (onExecuteCredentialsExchange) |
| Pre user registration | Pre User Registration (onExecutePreUserRegistration) |
| Post user registration | Post User Registration (onExecutePostUserRegistration) |
| Post change password | Post Change Password (onExecutePostChangePassword) |
| Send phone message | Send Phone Message (onExecuteSendPhoneMessage) |
The same rules apply: no callback, event for input, api for output, and secrets declared on the Action. Check the current Auth0 documentation for the exact trigger names in your tenant before you build, since the list has grown over time.
A Cutover Plan That Does Not Break Logins
The Auth0 guide recommends keeping Rules and Actions one-to-one, so you can turn each piece on and off independently. A workable sequence:
- Inventory. Export everything with the Deploy CLI (
a0deploy export --format yaml) or list Rules through the Management API. Record each Rule’s order, enabled flag and the secrets it reads. Include Hooks. - Classify. Mark each Rule as: delete (dead code and old experiments), port as-is, or merge. Many tenants find a third of their Rules are disabled or no-ops.
- Build in a non-production tenant. Dashboard > Actions > Library > Build Custom, trigger Login / Post Login, newest runtime. Paste the Rule below the
onExecutePostLoginsignature and apply the mapping table. - Test with real tokens. Log in with users that exercise each branch (unverified, no roles, first login, MFA) and decode the resulting ID and access tokens. Compare claim names and values with the production Rule output.
- Migrate from the end of the pipeline backward. Post-Login Actions run after existing Rules, so the last Rule is the safest to move first without changing evaluation order.
- Deploy, attach and disable in the same window. Deploy the Action, add it to the Login flow, then disable the Rule it replaces. Do these one right after the other, otherwise you either run both (double claims, double webhooks) or neither.
- Move the pipeline. Put Actions in the Deploy CLI config or Terraform, remove the Rule files, and keep the disabled Rules for a short rollback window until your Rule/Hook EOL date approaches.
If you maintain tenants as code, treat the Rule-to-Action switch like any config migration: promote dev, then staging, then production, with a decoded-token diff as the acceptance test.
Common Failure Modes
| Symptom | Cause | Fix |
|---|---|---|
callback is not a function | Rule code pasted with the callback line | Replace with return; or return api.access.deny(...) |
| Custom claim missing from the access token | Used setCustomClaim on idToken only, or claim has no namespace | Call both api.idToken and api.accessToken and use a URL namespace |
| Claims appear twice, or webhook fires twice | Rule and Action both active | Disable the Rule in the same change as attaching the Action |
Cannot read properties of undefined (reading 'x') | Relied on user.user_metadata shape | Guard optional objects; only app_metadata is guaranteed |
| Logins slow or fail at peak | Management API or HTTP call in the login path | Short timeouts, move work out of the flow, or use api.user.set* |
| Deploy CLI fails on a Rule | Code edit blocked in the read-only phase | Remove the Rule from the repo; the Action replaces it |
| Dependency not found | Pinned require('[email protected]') string kept | Add the dependency in the Dashboard and require('pkg') |
Should You Hurry?
Hooks have until April 30, 2027, Rules until January 31, 2028, and the Node 18 runtime until May 10, 2027. Nothing breaks this week. The reason to start now is that read-only already blocks bug fixes, every new requirement has to be built as an Action anyway, and a half-migrated tenant (some logic in Rules, some in Actions) is harder to reason about than either end state. Migrating a few Rules per sprint, starting with the ones you actually need to change, is the lowest-risk path. If you are weighing whether to stay on Auth0 at all, the IAM platform evaluation framework covers the other options, and Auth0 MAU pricing is worth reviewing in the same pass.
Sources: Auth0 Deprecations and Migrations, How to Migrate from Rules to Actions, Preparing for Rules and Hooks End of Life.





