Auth0 Universal Login with ACUL Setup Guide
Overview
This is an overview on how to customize Auth0 Native Universal Login via Advanced Customizations for Universal Login (ACUL).
These setup instructions build upon Auth0's ACUL samples repo, where the react-js sample is extended with Arkose integration on the login-id screen, built for the Native Universal Login + ACUL + Auth0 Bot Detection (Arkose provider) architecture:
- Auth0 passes the captcha context (provider, siteKey, clientSubdomain) into the ACUL screen.
- The screen loads the Arkose Client API and runs the challenge only on submit.
- The resulting one-time token is submitted to Auth0 as the
captchafield; Auth0 verifies it server-side with Arkose per session as part of the Auth0 Native Setup Guide - Universal Login.
Changed files (all under react-js/)
react-js/)| File | Change |
|---|---|
src/components/Captcha/providers/ArkoseWidget.tsx | NEW. Loads api.js at mount, setConfig with DX blob and callbacks; renders nothing except a fail-closed error line. |
src/components/Captcha/providers/arkoseController.ts | NEW. Singleton bridging the widget and submit handlers: DX blob builder, executeArkose("") fetching your server-side DX blob for session creation. |
src/components/Captcha/index.tsx | Registers arkose in the widget map; adds clientSubdomain to the config type and passes it through from the SDK captcha context. |
src/screens/login-id/components/LoginIdForm.tsx | onSubmit intercepts for the arkose provider: awaits executeArkose(username), then logs in with captcha: token; on failure, does not submit. |
public/screens/login-id/login-id/default.json | Mock context: screen.captcha with provider: "arkose", siteKey, clientSubdomain. |
Implementing ArkoseWidget.tsx
ArkoseWidget.tsx- Create the new file in the folder structure in your repo with the following sample code.
import React, { useEffect, useState } from "react";
import { cn } from "@/lib/utils";
import type { CaptchaResponse, CaptchaWidgetProps } from "../index";
import {
buildDxBlob,
handleCompleted,
handleDismissed,
handleFailure,
hasEnforcement,
markReady,
registerEnforcement,
teardown,
type ArkoseEnforcement,
} from "./arkoseController";
const ARKOSE_PROVIDER = "arkose";
const ARKOSE_CALLBACK = "setupArkoseEnforcement";
declare global {
interface Window {
[ARKOSE_CALLBACK]?: (enforcement: ArkoseEnforcement) => void;
}
}
// Triggered-mode Arkose widget: api.js is loaded and setConfig runs at page
// load, but the challenge (lightbox) only appears when a submit handler calls
// executeArkose() from the controller. Renders no visible UI except errors.
const ArkoseWidget: React.FC<CaptchaWidgetProps> = ({
config,
onCaptchaResponse,
className = "",
error,
}) => {
const [errorMessage, setErrorMessage] = useState<string | undefined>(error);
useEffect(() => {
const { siteKey, clientSubdomain } = config;
if (!siteKey || hasEnforcement()) return;
const host = `${clientSubdomain || "client-api"}.arkoselabs.com`; // replace with your Vanity Url FQDM where applicable.
window[ARKOSE_CALLBACK] = (enforcement: ArkoseEnforcement) => {
registerEnforcement(enforcement);
enforcement.setConfig({
data: buildDxBlob(""), // DataExchange (DX) blob
onReady: () => {
// Do NOT run here; the challenge is triggered on submit.
markReady();
},
onCompleted: (response: { token: string }) => {
// Fires for both interactive and suppressed sessions -- a real
// token is issued either way.
setErrorMessage(undefined);
handleCompleted(response.token);
const res: CaptchaResponse = {
provider: ARKOSE_PROVIDER,
arkoseToken: response.token,
};
onCaptchaResponse(res);
},
onSuppress: () => {
// Challenge UI suppressed; onCompleted still fires with the token.
},
onError: (response: unknown) => {
// Fail CLOSED: the pending submit's promise rejects and the login
// is never sent without a token.
setErrorMessage("Challenge server error. Please try again.");
handleFailure(response);
onCaptchaResponse(null);
},
onHide: () => {
// User closed the lightbox with X (also fires after completion):
// release the pending submit WITHOUT resetting, so the next click
// re-runs the same Arkose session.
handleDismissed(new Error("Challenge dismissed"));
},
});
};
const script = document.createElement("script");
script.src = `https://${host}/v2/${siteKey}/api.js`;
script.async = true;
script.defer = true;
script.setAttribute("data-callback", ARKOSE_CALLBACK);
document.body.appendChild(script);
return () => {
delete window[ARKOSE_CALLBACK];
document.body.removeChild(script);
teardown();
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [config.siteKey, config.clientSubdomain]);
useEffect(() => {
setErrorMessage(error);
}, [error]);
if (config.provider !== ARKOSE_PROVIDER || !config.siteKey) {
return null;
}
if (!errorMessage) {
return null;
}
return (
<div
role="alert"
className={cn("mb-4 text-sm text-[#d93025]", className)}
>
{errorMessage}
</div>
);
};
export default ArkoseWidget;Implementing arkoseController.ts
arkoseController.ts- Create the new file in the folder structure in your repo with the following sample code.
// Bridges the ArkoseWidget (which owns the Enforcement object created by
// api.js) and screen submit handlers (which trigger the challenge on submit).
// Module-level singleton: exactly one Arkose enforcement exists per page.
export interface ArkoseEnforcement {
setConfig: (config: Record<string, unknown>) => void;
run: () => void;
reset?: () => void;
}
interface UlContext {
prompt?: { name?: string };
screen?: { name?: string };
}
let enforcement: ArkoseEnforcement | null = null;
let ready = false;
let dxDataSet = false;
let dxEmail: string | null = null;
let pending: {
resolve: (token: string) => void;
reject: (err: unknown) => void;
} | null = null;
function getUlContext(): UlContext {
return (
(window as unknown as { universal_login_context?: UlContext })
.universal_login_context || {}
);
}
// Call your Server-side API for the DX blob to be passed in to the Client-API.
export function buildDxBlob(email: string) {
// THIS NEEDS TO CALL YOUR ENCRYPTED DX API TO GET THE BLOB, NOT JUST RETURN THE RAW DATA. The following blob is a placeholder for demonstration purposes. In a real implementation, you would call your server-side API to get the encrypted blob.
return { blob: "<ENCRYPTED DX BLOB STRING YOU FETCHED>" };
}
export function hasEnforcement(): boolean {
return enforcement !== null;
}
export function registerEnforcement(e: ArkoseEnforcement): void {
enforcement = e;
}
export function markReady(): void {
ready = true;
}
export function handleCompleted(token: string): void {
pending?.resolve(token);
pending = null;
}
export function handleFailure(err: unknown): void {
pending?.reject(err);
pending = null;
// Arkose tokens are single-use and errors invalidate the session:
// reset so the next submit starts a fresh challenge with fresh DX.
enforcement?.reset?.();
dxDataSet = false;
dxEmail = null;
}
// For onHide (user closed the lightbox with X, or it closed after
// completion): release the pending submit but keep the session and DX
// intact, so the next run() re-shows the SAME session instead of
// creating a new one.
export function handleDismissed(err: unknown): void {
pending?.reject(err);
pending = null;
}
// Resolves with a one-time token after the challenge completes (interactive
// or suppressed). Rejects if Arkose is not ready or errored: callers must
// fail closed and NOT submit the login without a token.
export function executeArkose(email: string): Promise<string> {
return new Promise((resolve, reject) => {
if (!enforcement || !ready) {
reject(new Error("Arkose enforcement not ready"));
return;
}
if (pending) {
reject(new Error("Arkose challenge already running"));
return;
}
pending = { resolve, reject };
// The DX blob is committed to the session at first submit. Calling
// setConfig({ data }) with a new blob triggers a reset internally in
// Arkose client-api (no explicit reset() needed), so it is called only
// when the identifier is new or has changed; a retry with the same
// identifier keeps the session and just calls run().
if (!dxDataSet || dxEmail !== email) {
enforcement.setConfig({ data: buildDxBlob(email) });
dxDataSet = true;
dxEmail = email;
}
enforcement.run();
});
}
export function teardown(): void {
enforcement = null;
ready = false;
dxDataSet = false;
dxEmail = null;
pending = null;
}Implementing index.tsx
index.tsx- Open existing file.
- Register
arkosein the widget map. - Add
clientSubdomainto the config type. - Pass it through from the SDK captcha context.
Sample code:
import React from "react";
import { Control, FieldValues, Path, RegisterOptions } from "react-hook-form";
import type { CaptchaContext } from "@auth0/auth0-acul-js/types";
import AuthChallengeWidget from "./providers/AuthChallengeWidget";
import FriendlyCaptchaWidget from "./providers/FriendlyCaptchaWidget";
import HCaptchaWidget from "./providers/HCaptchaWidget";
import RecaptchaCombinedWidget from "./providers/ReCaptchaCombinedWidget";
import SimpleCaptchaWidget from "./providers/SimpleCaptchaWidget";
import ArkoseWidget from "./providers/ArkoseWidget";
// ---
// Interfaces
// ---
export interface CaptchaResponse {
provider: string;
token?: string;
answer?: string;
arkoseToken?: string;
}
export interface CaptchaWidgetProps<T extends FieldValues = FieldValues> {
config: {
provider: string;
siteKey?: string;
clientSubdomain?: string; // added - Arkose client-api host
image?: string;
size?: string;
placeholder?: string;
};
control?: Control<T>;
rules?: RegisterOptions<T>;
name: Path<T>;
onCaptchaResponse: (response: CaptchaResponse | null) => void;
className?: string;
label?: string;
theme?: "light" | "dark" | "auto";
error?: string;
}
// Use SDK's CaptchaContext interface
export type ICaptcha = CaptchaContext;
export interface CaptchaProps<T extends FieldValues = FieldValues> {
captcha?: ICaptcha;
onValidationChange?: (
isValid: boolean,
value?: string,
error?: string
) => void;
label?: string;
sdkError?: string;
theme?: "light" | "dark" | "auto";
className?: string;
control: Control<T>;
rules?: RegisterOptions<T>;
name: Path<T>;
}
// ---
// Main Component
// ---
const Captcha = <T extends FieldValues = FieldValues>({
control,
rules,
name,
captcha,
onValidationChange,
label,
sdkError,
theme,
className,
}: CaptchaProps<T>) => {
// ---
// Constants and Mappings
// ---
function getCaptchaWidgetMap<T extends FieldValues>() {
return {
recaptcha_v2: RecaptchaCombinedWidget as React.ComponentType<
CaptchaWidgetProps<T>
>,
recaptcha_enterprise: RecaptchaCombinedWidget as React.ComponentType<
CaptchaWidgetProps<T>
>,
hcaptcha: HCaptchaWidget as React.ComponentType<CaptchaWidgetProps<T>>,
auth0_v2: AuthChallengeWidget as React.ComponentType<
CaptchaWidgetProps<T>
>,
friendly_captcha: FriendlyCaptchaWidget as React.ComponentType<
CaptchaWidgetProps<T>
>,
arkose: ArkoseWidget as React.ComponentType<CaptchaWidgetProps<T>>,
};
}
const CAPTCHA_WIDGET_MAP = getCaptchaWidgetMap<T>();
const { provider, image, siteKey, clientSubdomain } = captcha || {};
// If no provider is specified, render nothing.
if (!provider) {
return null;
}
const handleResponse = (res: CaptchaResponse | null) => {
if (onValidationChange) {
if (res) {
const value =
res.provider === "auth0" ? res.answer : res.token || res.arkoseToken;
const isValid = !!value;
onValidationChange(isValid, value);
} else {
onValidationChange(false);
}
}
};
// Handle the special case for SimpleCaptchaWidget (Auth0 v1)
if (provider === "auth0") {
return image ? (
<SimpleCaptchaWidget
config={{ provider: "auth0", image }}
onCaptchaResponse={handleResponse}
control={control}
name={name}
rules={rules}
label={label}
error={sdkError}
className={className}
/>
) : null;
}
// Use the map for other providers
const SpecificCaptchaWidget =
CAPTCHA_WIDGET_MAP[provider as keyof typeof CAPTCHA_WIDGET_MAP];
if (SpecificCaptchaWidget && siteKey) {
return (
<SpecificCaptchaWidget
config={{ provider, siteKey, clientSubdomain }}
name={name}
onCaptchaResponse={handleResponse}
theme={theme}
label={label}
error={sdkError}
className={className}
/>
);
}
return null;
};
export default Captcha;Implementing LoginIdForm.tsx
LoginIdForm.tsx- Open existing file.
onSubmitintercept for the arkose provider: awaitsexecuteArkose(username), then logs in withcaptcha: token- On failure, do not submit.
Sample code:
import { useEffect, useRef } from "react";
import { useForm } from "react-hook-form";
import type { Error, LoginOptions } from "@auth0/auth0-acul-js/types";
import Captcha from "@/components/Captcha/index";
import { executeArkose } from "@/components/Captcha/providers/arkoseController";
import { ULThemeFloatingLabelField } from "@/components/form/ULThemeFloatingLabelField";
import { ULThemeFormMessage } from "@/components/form/ULThemeFormMessage";
import { Form, FormField, FormItem } from "@/components/ui/form";
import ULThemeCountryCodePicker from "@/components/ULThemeCountryCodePicker";
import { ULThemeAlert, ULThemeAlertTitle } from "@/components/ULThemeError";
import ULThemeLink from "@/components/ULThemeLink";
import { ULThemePrimaryButton } from "@/components/ULThemePrimaryButton";
import { useCaptcha } from "@/hooks/useCaptcha";
import {
isPhoneNumberSupported,
transformAuth0CountryCode,
} from "@/utils/helpers/countryUtils";
import { getFieldError } from "@/utils/helpers/errorUtils";
import { getIdentifierDetails } from "@/utils/helpers/identifierUtils";
import { useLoginIdManager } from "../hooks/useLoginIdManager";
function LoginIdForm() {
const {
handleLoginId,
loginIdInstance,
screen,
transaction,
handlePickCountryCode,
locales,
} = useLoginIdManager();
const form = useForm<LoginOptions>({
defaultValues: {
username: "",
captcha: "",
},
});
const {
formState: { isSubmitting },
} = form;
// Destructure from screen and transaction
const { texts, isCaptchaAvailable, captcha, resetPasswordLink } = screen;
const { isForgotPasswordEnabled, countryCode, countryPrefix } = transaction;
// Get computed values from SDK instance
const errors = loginIdInstance.getErrors();
const loginIdentifiers = loginIdInstance.getLoginIdentifiers() || [];
// Handle text fallbacks using locales
const buttonText = texts?.buttonText || locales.form.button;
const captchaLabel = texts?.captchaCodePlaceholder
? `${texts.captchaCodePlaceholder}*`
: `${locales.form.fields.captcha.label}*`;
const forgotPasswordText =
texts?.forgotPasswordText || locales.form.forgotPassword;
const {
label: identifierLabel,
type: identifierType,
autoComplete: identifierAutoComplete,
} = getIdentifierDetails(loginIdentifiers, texts);
const generalErrors =
errors?.filter((error: Error) => !error.field || error.field === null) ||
[];
const identifierSDKError =
getFieldError("identifier", errors) ||
getFieldError("email", errors) ||
getFieldError("phone", errors) ||
getFieldError("username", errors);
const captchaSDKError = getFieldError("captcha", errors);
const { captchaConfig, captchaProps } = useCaptcha(
captcha || undefined,
captchaLabel
);
const shouldShowCountryPicker = isPhoneNumberSupported(loginIdentifiers);
const selectedCountry = transformAuth0CountryCode(countryCode, countryPrefix);
// Track if passkey autofill has been registered to prevent duplicate calls
const passkeyRegistered = useRef(false);
// Enable passkey autofill for identifier field if supported (run once on mount)
useEffect(() => {
if (
transaction?.isPasskeyEnabled &&
transaction?.showPasskeyAutofill &&
!passkeyRegistered.current
) {
passkeyRegistered.current = true;
loginIdInstance.registerPasskeyAutofill("username");
}
}, [
transaction?.isPasskeyEnabled,
transaction?.showPasskeyAutofill,
loginIdInstance,
]);
// Proper submit handler with form data
const onSubmit = async (data: LoginOptions): Promise<void> => {
// Arkose is run on submit, then log in
// with the one-time token. Fail closed if no token was obtained.
if (captchaConfig?.provider === "arkose") {
try {
const token = await executeArkose(String(data.username || ""));
await handleLoginId({ ...data, captcha: token });
} catch (e) {
console.warn("Arkose challenge not completed; login not submitted", e);
}
return;
}
await handleLoginId(data);
};
return (
<Form {...form}>
<form onSubmit={form.handleSubmit(onSubmit)}>
{/* General alerts at the top */}
{generalErrors.length > 0 && (
<div className="space-y-3 mb-4">
{generalErrors.map((error: Error, index: number) => (
<ULThemeAlert key={index} variant="destructive">
<ULThemeAlertTitle>{error.message}</ULThemeAlertTitle>
</ULThemeAlert>
))}
</div>
)}
{/* Country Code Picker - only show if phone numbers are supported */}
{shouldShowCountryPicker && (
<div className="mb-4">
<ULThemeCountryCodePicker
selectedCountry={selectedCountry}
onClick={handlePickCountryCode}
fullWidth
placeholder={locales.form.countryPicker.placeholder}
/>
</div>
)}
{/* Identifier input field */}
<FormField
control={form.control}
name="username"
rules={{
required: locales.form.fields.identifier.required,
}}
render={({ field, fieldState }) => (
<FormItem>
<ULThemeFloatingLabelField
{...field}
id="username"
value={String(field.value || "")}
label={identifierLabel}
type={identifierType}
autoFocus={true}
autoComplete={identifierAutoComplete}
error={!!fieldState.error || !!identifierSDKError}
/>
<ULThemeFormMessage
sdkError={identifierSDKError}
hasFormError={!!fieldState.error}
/>
</FormItem>
)}
/>
{/* CAPTCHA Box */}
{isCaptchaAvailable && captchaConfig && (
<Captcha
control={form.control}
name="captcha"
captcha={captchaConfig}
{...captchaProps}
sdkError={captchaSDKError}
rules={{
required: locales.form.fields.captcha.required,
}}
/>
)}
{/* Forgot Password link */}
{isForgotPasswordEnabled && resetPasswordLink && (
<div className="mb-4 mt-2 text-left">
<ULThemeLink href={resetPasswordLink}>
{forgotPasswordText}
</ULThemeLink>
</div>
)}
{/* Submit button */}
<ULThemePrimaryButton
type="submit"
className="w-full"
disabled={isSubmitting}
>
{buttonText}
</ULThemePrimaryButton>
</form>
</Form>
);
}
export default LoginIdForm;Implementing default.json
default.json- Open existing file.
- Set mock context:
screen.captchawithprovider: "arkose", siteKey,clientSubdomain
...
"screen": {
"captcha": {
"provider": "arkose",
"siteKey": "<PUBLIC KEY HERE>",
"clientSubdomain": "<CUSTOMER NAME>-api"
},
...Updated about 8 hours ago