Account Linking: AuthHero vs. Auth0
Account linking allows multiple authentication identities (e.g., email/password, Google, Facebook) to be associated with a single user profile. This is essential for providing a seamless user experience when users sign in through different methods.
In AuthHero, linking is driven by the account-linking template hook — a pre-defined, idempotent function shipped with the library and selected by template_id. It is not user-authored code (a CodeHook / Auth0 Action) — there is no JavaScript blob to write or deploy. Tenants enable it per trigger via the management API, and a service-level + per-client userLinkingMode toggle controls whether the legacy built-in path runs alongside it.
Quick Comparison
| Feature | Auth0 | AuthHero |
|---|---|---|
| Automatic Email Linking | ❌ Requires custom Action | ✅ account-linking template (or legacy built-in path) |
| Manual Linking via Hook | Requires Management API calls | ✅ Simple setLinkedTo() API |
| Cross-Connection Linking | Complex setup required | ✅ Automatic when emails match |
| Primary User Selection | Manual implementation | ✅ Automatic (first verified account) |
| Chain Linking Prevention | Manual implementation | ✅ Built-in |
| Tenant Isolation | N/A (single tenant) | ✅ Automatic per-tenant |
| Case-Insensitive Matching | Manual implementation | ✅ Built-in |
The Problem with Auth0 Account Linking
In Auth0, account linking requires significant custom implementation:
1. Manual Action Implementation
You need to create a custom Action that:
- Searches for existing users with matching emails
- Calls the Management API to link accounts
- Handles token refresh for API calls
- Manages edge cases and errors
// Auth0 Action (complex implementation)
exports.onExecutePostLogin = async (event, api) => {
const ManagementClient = require('auth0').ManagementClient;
// Need to create Management API client
const management = new ManagementClient({
domain: event.secrets.domain,
clientId: event.secrets.clientId,
clientSecret: event.secrets.clientSecret,
});
// Search for existing users
const users = await management.getUsersByEmail(event.user.email);
// Complex logic to find primary account
const primaryUser = users.find(u => u.user_id !== event.user.user_id);
if (primaryUser) {
// Manual linking via API call
await management.linkUsers(primaryUser.user_id, {
user_id: event.user.user_id,
provider: event.connection.strategy,
});
}
};2. Common Issues with Auth0 Approach
- Race conditions: Multiple simultaneous logins can create duplicate users
- Token management: Management API tokens need refresh handling
- Error recovery: Failed linking leaves orphan accounts
- No pre-registration hook: Linking happens after user creation, not during
AuthHero's Account Linking
AuthHero ships email-based linking as a pre-defined template hook called account-linking. The same idempotent function backs three triggers — post-user-login, post-user-registration, and post-user-update — so the template covers signup, social-callback, and email-verification flows. A legacy built-in path that runs the same lookup transactionally inside commitUserHook is enabled by default for backwards compatibility, and is controlled by userLinkingMode.
Configuring userLinkingMode
The service-level userLinkingMode option on init() controls the legacy path:
| Mode | Built-in path | Template path |
|---|---|---|
"builtin" (default) | Runs at user creation and email update | Runs only if a tenant explicitly enables it |
"off" | Skipped entirely | Runs only if a tenant explicitly enables it |
The template hook is controlled independently per tenant and trigger via the management API, regardless of mode. A tenant on "builtin" mode can still enable the template at post-user-login (which the built-in never covers) to catch legacy unlinked accounts. Running both at the same trigger is harmless but redundant — the template no-ops once the built-in has set linked_to.
init({
dataAdapter,
userLinkingMode: "off", // long-term destination — template-only
});A per-client user_linking_mode field overrides the service-level default for a single application. This is useful when you want to validate the template-driven path on one app before flipping the whole tenant:
await data.clients.update(tenantId, clientId, {
user_linking_mode: "off",
});Resolution order: per-client → service-level → "builtin" fallback.
Automatic Email-Based Linking
When a new user signs up with a verified email that matches an existing user, AuthHero links the accounts:
// Default behaviour (userLinkingMode: "builtin"):
// 1. Google returns verified email: user@example.com
// 2. commitUserHook finds the existing primary with the same email
// 3. linked_to is set atomically inside the commit transaction
// 4. Login returns the primary user with both identities
// Template behaviour (userLinkingMode: "off" + account-linking template enabled):
// 1. Google returns verified email: user@example.com
// 2. commitUserHook commits the new user (no linking decision)
// 3. The post-user-registration template hook runs
// 4. account-linking sets linked_to via users.update — same end stateRequirements for Automatic Linking
Linking happens (via either path) when all of these conditions are met:
- ✅ The new or updated account has a verified email
- ✅ An existing account has the same email (case-insensitive)
- ✅ Both accounts are in the same tenant
Email Verification Required
Unverified emails are never automatically linked. This prevents account takeover attacks where an attacker could claim any email address.
Manual Linking via Hooks
For advanced use cases, AuthHero provides the setLinkedTo() method in the pre-registration hook:
import { init } from "@authhero/authhero";
const auth = init({
dataAdapter: myAdapter,
hooks: {
onExecutePreUserRegistration: async (event, api) => {
// Link to a specific user regardless of email
const targetUserId = await lookupUserInExternalSystem(event.user.email);
if (targetUserId) {
api.user.setLinkedTo(targetUserId);
}
},
},
});Use Cases for Manual Linking
- Migration from Another System: Link users based on external IDs
- Organization-Based Linking: Link users within the same organization
- Custom Matching Logic: Link based on phone number or other attributes
- Override Automatic Linking: Link to a different user than email would suggest
The account-linking Template
The template is AuthHero's equivalent of Auth0's "Account Linking" Dashboard Extension, but it is not user-authored code. It is a pre-defined function shipped with authhero and dispatched from handleTemplateHook when a tenant enables a TemplateHook row with template_id: "account-linking". There is no JavaScript blob to deploy, no code-executor invocation, no secrets to manage — the runtime calls the registered function directly.
The same idempotent function backs three triggers:
| Trigger | When it runs | Replaces the built-in path at... |
|---|---|---|
post-user-registration | After a new user is committed | User creation (covers what commitUserHook does today) |
post-user-update | After users.update commits an email or email_verified change | Email-update auto-link in createUserUpdateHooks |
post-user-login | After every successful login | Catches accounts that already exist and never linked |
It is fully idempotent: no-op when linked_to is already set, when the email is unverified, or when the user is already the primary. Running it on every trigger is safe.
Per-tenant via the admin API / UI:
await data.hooks.create(tenantId, {
trigger_id: "post-user-registration",
template_id: "account-linking",
enabled: true,
});
// Repeat for post-user-update and/or post-user-login as needed.Globally at the post-login trigger via init:
import { init, preDefinedHooks } from "authhero";
const auth = init({
dataAdapter,
hooks: {
onExecutePostLogin: preDefinedHooks.accountLinking(),
},
});Options
preDefinedHooks.accountLinking({
// default: true. Disabling is almost never what you want — it enables
// account takeover via unverified email on an untrusted connection.
requireVerifiedEmail: true,
// default: false. When true, merges the secondary user's user_metadata
// into the primary's on link. Existing keys on the primary are NOT
// overwritten (primary wins on conflict). app_metadata is never copied.
copyUserMetadata: false,
});When the template is enabled per-tenant via data.hooks.create, options are read from the hook's metadata field:
await data.hooks.create(tenantId, {
trigger_id: "post-user-registration",
template_id: "account-linking",
enabled: true,
metadata: { copy_user_metadata: true },
});The metadata field is a generic property bag on every hook variant.
Inheriting templates from the control plane
In a multi-tenant deployment using @authhero/multi-tenancy, a single hook on the control-plane tenant can be surfaced to every sub-tenant by setting metadata.inheritable: true. The runtime fallback in withRuntimeFallback merges those rows into each sub-tenant's hooks.list and hooks.get results. Inherited hooks are read-only from a sub-tenant's perspective — hooks.update / hooks.remove calls scoped to the sub-tenant's tenant_id cannot touch a row owned by the control plane.
// On the control-plane tenant — applies to every sub-tenant.
await data.hooks.create(controlPlaneTenantId, {
trigger_id: "post-user-registration",
template_id: "account-linking",
enabled: true,
metadata: { inheritable: true, copy_user_metadata: true },
});A sub-tenant can still create its own hook for the same trigger; both run.
When to use the template vs. the built-in
| Scenario | Use |
|---|---|
| Existing deployment, no migration desired | Leave userLinkingMode: "builtin" (default); no config needed |
| Validate the template path on one client before flipping the tenant | Set client.user_linking_mode = "off" and enable the template for that trigger |
| Move the whole tenant to template-driven linking | userLinkingMode: "off" + enable the template at the triggers you care about |
| Legacy unlinked accounts should merge when the user next logs in | Enable the template at post-user-login |
| Custom matching rules (not just email) | setLinkedTo in pre-user-registration |
| Every login should re-check linking (e.g. after email verification flips) | Enable the template at post-user-login |
The template is safe to combine with either the built-in path or a custom pre-user-registration hook — all three resolve to the same linked_to field atomically via users.update.
How Account Linking Works
Data Model
In AuthHero, linked accounts have a linked_to field pointing to the primary user:
Primary User: auth0|user123
├── email: user@example.com
├── linked_to: null (primary users have no linked_to)
└── identities: [
{ provider: "auth0", user_id: "user123" },
{ provider: "google-oauth2", user_id: "google456" },
{ provider: "facebook", user_id: "fb789" }
]
Secondary User: google-oauth2|google456
├── email: user@example.com
├── linked_to: "auth0|user123" ← Points to primary
└── identities: (included in primary's identities)Login Behavior
When a user logs in with a linked account:
- AuthHero identifies the linked identity
- The primary user is returned (not the secondary)
- All identities are included in the
identitiesarray - Tokens are issued for the primary user's
user_id
Chain Linking Prevention
AuthHero automatically prevents chain linking. New accounts always link to the primary user, never to another secondary:
✅ Correct:
Primary ← Secondary1
Primary ← Secondary2
Primary ← Secondary3
❌ Prevented (chain linking):
Primary ← Secondary1 ← Secondary2 ← Secondary3Example: Complete Hook Implementation
Here's a comprehensive example showing various account linking scenarios:
import { init } from "@authhero/authhero";
const auth = init({
dataAdapter: myAdapter,
hooks: {
onExecutePreUserRegistration: async (event, api) => {
// Example 1: Link based on external CRM ID
const crmUser = await lookupCRM(event.user.email);
if (crmUser?.authhero_user_id) {
api.user.setLinkedTo(crmUser.authhero_user_id);
return;
}
// Example 2: Link employees to company account
if (event.user.email?.endsWith("@mycompany.com")) {
const companyPrimaryUser = await findCompanyPrimaryUser(event.user.email);
if (companyPrimaryUser) {
api.user.setLinkedTo(companyPrimaryUser.user_id);
return;
}
}
// Example 3: Block linking for certain domains
if (event.user.email?.endsWith("@blocked-domain.com")) {
// Don't set linked_to - user will be created as separate account
// even if email matches (overrides automatic linking)
api.user.setUserMetadata("linking_blocked", true);
return;
}
// For all other cases, automatic email-based linking applies
},
},
});Security Considerations
Built-in Protections
AuthHero includes several security measures for account linking:
| Protection | Description |
|---|---|
| Email Verification | Only verified emails trigger automatic linking |
| Tenant Isolation | Users can only be linked within the same tenant |
| Primary User Validation | setLinkedTo() validates the target user exists |
| No Chain Linking | Secondary accounts cannot link to other secondary accounts |
| Case Normalization | Emails are compared case-insensitively to prevent bypasses |
When NOT to Link
Automatic linking is skipped when:
- Email is not verified
- User has no email (e.g., SMS-only authentication)
- No matching user exists in the same tenant
linked_tois explicitly set via hook (takes precedence)
Migration from Auth0
If you're migrating from Auth0 and have custom account linking logic:
1. Remove Auth0 Actions
You can typically remove your Auth0 account linking Actions entirely, as AuthHero handles this automatically.
2. Migrate Existing Links
Export linked accounts from Auth0 and import with linked_to set:
// Migration script
for (const auth0User of auth0Users) {
if (auth0User.identities.length > 1) {
// Primary user
const primaryUserId = `${auth0User.identities[0].provider}|${auth0User.identities[0].user_id}`;
await authHero.users.create({
...auth0User,
user_id: primaryUserId,
linked_to: undefined, // Primary users have no linked_to
});
// Secondary identities
for (const identity of auth0User.identities.slice(1)) {
await authHero.users.create({
email: auth0User.email,
user_id: `${identity.provider}|${identity.user_id}`,
linked_to: primaryUserId,
// ... other fields
});
}
}
}3. Implement Custom Logic (if needed)
Only if you had special linking logic beyond email matching:
// Replicate custom Auth0 Action logic
onExecutePreUserRegistration: async (event, api) => {
// Your custom matching logic here
const targetUser = await yourCustomLookup(event.user);
if (targetUser) {
api.user.setLinkedTo(targetUser.user_id);
}
}Summary
AuthHero significantly simplifies account linking compared to Auth0:
| Aspect | Auth0 | AuthHero |
|---|---|---|
| Setup Required | Custom Action + API calls | None (built-in) |
| Code Complexity | 50+ lines | 0-10 lines |
| Error Handling | Manual | Automatic |
| Edge Cases | Manual implementation | Handled automatically |
| Customization | Complex | Simple setLinkedTo() |
For most applications, AuthHero's automatic email-based linking works out of the box with no configuration required.