Skip to main content

Authentication recipes

Task-oriented snippets that go beyond the Email OTP tutorial. Pick a recipe, drop the snippet in.

Assumed setup

All snippets assume your app is already wrapped with KeybanProvider + KeybanAuthProvider and that the relevant method is enabled in the Admin Panel. Function signatures live in the auto-generated SDK reference: useKeybanAuth and KeybanInput.

Recipe overview

Sign-in methods

Three drop-in components, one per method. The shape is always the same — guard with config[method].enabled, call signIn (or sendOtp first for OTP), surface errors.

Phone OTP with country code

Two-step flow. The phoneCallingCode is captured separately from the input so the user can pick a country independently of the number itself.

import { KeybanInput, useKeybanAuth } from "@keyban/sdk-react";
import { useState } from "react";

export default function PhoneOTPLogin() {
const [step, setStep] = useState<"phone" | "otp">("phone");
const [error, setError] = useState<string | null>(null);
const [phoneCallingCode, setPhoneCallingCode] = useState("33");
const { sendOtp, signIn, isAuthenticated, isLoading } = useKeybanAuth();

const handlePhoneSubmit = async () => {
setError(null);
try {
await sendOtp({
type: "phone-otp",
phoneCallingCode,
phoneInputName: "phone",
});
setStep("otp");
} catch (err) {
setError("Failed to send OTP. Please check the phone number and try again.");
console.error(err);
}
};

const handleOtpSubmit = async () => {
setError(null);
try {
await signIn({
type: "phone-otp",
phoneCallingCode,
phoneInputName: "phone",
otpInputName: "otp",
});
} catch (err) {
setError("Invalid OTP. Please try again.");
console.error(err);
}
};

if (isAuthenticated) return <div>You're in!</div>;

return (
<form onSubmit={(e) => e.preventDefault()}>
{error && <div style={{ color: "red" }}>{error}</div>}
{step === "phone" ? (
<div>
<label>Phone Number:</label>
<select
value={phoneCallingCode}
onChange={(e) => setPhoneCallingCode(e.target.value)}
>
<option value="33">+33 (France)</option>
<option value="1">+1 (USA)</option>
<option value="44">+44 (UK)</option>
</select>
<KeybanInput name="phone" type="tel" />
<button type="button" onClick={handlePhoneSubmit} disabled={isLoading}>
{isLoading ? "Sending..." : "Send Code"}
</button>
</div>
) : (
<div>
<label>Verification Code:</label>
<KeybanInput
name="otp"
inputMode="numeric"
inputStyles={{ textAlign: "center" }}
/>
<button type="button" onClick={handleOtpSubmit} disabled={isLoading}>
{isLoading ? "Verifying..." : "Verify"}
</button>
</div>
)}
</form>
);
}

Google OAuth

A single button kicks off the OAuth redirect.

import { useKeybanAuth } from "@keyban/sdk-react";
import { useState } from "react";

export default function GoogleLogin() {
const { signIn, isLoading, config } = useKeybanAuth();
const [error, setError] = useState<string | null>(null);

if (!config["google"].enabled) return null;

const handleGoogleLogin = async () => {
setError(null);
try {
await signIn({ type: "google" });
} catch (err) {
setError("Failed to sign in with Google. Please try again.");
console.error(err);
}
};

return (
<>
{error && <div style={{ color: "red" }}>{error}</div>}
<button type="button" onClick={handleGoogleLogin} disabled={isLoading}>
{isLoading ? "Signing in..." : "Sign in with Google"}
</button>
</>
);
}

Auth0 enterprise SSO

Same shape as Google, but the button surfaces the organization's branding (logo + name) so end users recognise the SSO target.

import { useKeybanAuth, useKeybanApplication } from "@keyban/sdk-react";
import { useState } from "react";

export default function Auth0Login() {
const { signIn, isLoading, config } = useKeybanAuth();
const [app] = useKeybanApplication();
const [error, setError] = useState<string | null>(null);

if (!config["auth0"].enabled) return null;

const handleAuth0Login = async () => {
setError(null);
try {
await signIn({ type: "auth0" });
} catch (err) {
setError("Failed to sign in with Auth0. Please try again.");
console.error(err);
}
};

return (
<>
{error && <div style={{ color: "red" }}>{error}</div>}
<button type="button" onClick={handleAuth0Login} disabled={isLoading}>
{app.organization.logo && (
<img
src={app.organization.logo}
alt={`${app.organization.name} logo`}
style={{ width: 20, height: 20, marginRight: 8 }}
/>
)}
{isLoading ? "Signing in..." : `Sign in with ${app.organization.name}`}
</button>
</>
);
}

Adaptive UI

Build a single form that adapts to whatever authConfig the backend returns — credential methods on top as tabs, social/SSO methods below as buttons. The form picks up admin changes on the next config fetch without a code change.

Why this matters

The set of enabled methods can change at any time (admin flips a toggle, a new organization joins). Hard-coded forms drift; adaptive forms stay correct.

import { useKeybanAuth, AuthMethod } from "@keyban/sdk-react";

function DynamicAuthForm() {
const { config } = useKeybanAuth();

const credentialMethods = [
AuthMethod.EmailOtp,
AuthMethod.PhoneOtp,
].filter((method) => config[method].enabled);

const socialMethods = [
AuthMethod.Google,
AuthMethod.Auth0,
].filter((method) => config[method].enabled);

if (credentialMethods.length === 0 && socialMethods.length === 0) {
return <p>Authentication is not configured. Contact your administrator.</p>;
}

return (
<div>
{credentialMethods.length > 0 && (
<div className="tabs">
{credentialMethods.map((method) => (
<button key={method} className="tab">
{getMethodLabel(method)}
</button>
))}
</div>
)}

{credentialMethods.length > 0 && socialMethods.length > 0 && (
<div className="separator">or continue with</div>
)}

{socialMethods.length > 0 && (
<div className="social-auth">
{socialMethods.map((method) => (
<SocialButton key={method} method={method} />
))}
</div>
)}
</div>
);
}

function getMethodLabel(method: AuthMethod): string {
const labels: Record<AuthMethod, string> = {
[AuthMethod.EmailOtp]: "Email OTP",
[AuthMethod.PhoneOtp]: "Phone OTP",
};
return labels[method] ?? method;
}

Form integrations

The pattern: wrap KeybanInput so the host component thinks it owns a regular input, while the actual value still lives inside the Keyban iframe. Most field libraries expose a slot or render-prop for this — Material UI's slots.htmlInput is shown below, but the same approach works with Mantine, Chakra, react-hook-form Controller, etc.

Material UI

TextField accepts a slots.htmlInput override; pass KeybanInput through it and the field keeps the Material UI styling.

import { KeybanInput, useKeybanAuth } from "@keyban/sdk-react";
import { TextField, Button, Box } from "@mui/material";
import { useState } from "react";

export default function MaterialUILogin() {
const [showOtp, setShowOtp] = useState(false);
const { sendOtp, signIn } = useKeybanAuth();

const handleSendCode = async () => {
await sendOtp({ type: "email-otp", emailInputName: "email" });
setShowOtp(true);
};

const handleVerifyCode = async () => {
await signIn({
type: "email-otp",
emailInputName: "email",
otpInputName: "otp",
});
};

return (
<Box sx={{ display: "flex", flexDirection: "column", gap: 2 }}>
<TextField
label="Email"
variant="outlined"
fullWidth
slots={{
htmlInput: () => (
<Box
component={KeybanInput}
name="email"
type="email"
sx={{ flexGrow: 1 }}
/>
),
}}
/>

{showOtp && (
<TextField
label="Verification Code"
variant="outlined"
fullWidth
slots={{
htmlInput: () => (
<Box
component={KeybanInput}
name="otp"
inputMode="numeric"
inputStyles={{ textAlign: "center" }}
sx={{ flexGrow: 1 }}
/>
),
}}
/>
)}

<Button
variant="contained"
onClick={showOtp ? handleVerifyCode : handleSendCode}
>
{showOtp ? "Verify Code" : "Continue with Email"}
</Button>
</Box>
);
}

International phone input with mui-tel-input

MuiTelInput handles country selection and parsing; KeybanInput only owns the actual phone number value. The two cooperate via the slots.htmlInput slot.

import { KeybanInput, useKeybanAuth } from "@keyban/sdk-react";
import { MuiTelInput } from "mui-tel-input";
import { useState } from "react";
import { Box } from "@mui/material";

const PhoneInput = () => (
<Box
component={KeybanInput}
type="tel"
name="phone"
sx={{ flexGrow: 1, p: 2, pl: 0 }}
/>
);

export default function PhoneLogin() {
const [phoneCallingCode, setPhoneCallingCode] = useState("33");
const [showOtp, setShowOtp] = useState(false);
const { sendOtp, signIn } = useKeybanAuth();

const handleSendCode = async () => {
await sendOtp({
type: "phone-otp",
phoneCallingCode,
phoneInputName: "phone",
});
setShowOtp(true);
};

const handleVerifyCode = async () => {
await signIn({
type: "phone-otp",
phoneCallingCode,
phoneInputName: "phone",
otpInputName: "otp",
});
};

return (
<div>
<MuiTelInput
name="phone"
defaultCountry="FR"
value={`+${phoneCallingCode}`}
forceCallingCode
onChange={(_, infos) =>
setPhoneCallingCode(infos.countryCallingCode ?? "")
}
fullWidth
slots={{ htmlInput: PhoneInput }}
/>

{showOtp && (
<KeybanInput
name="otp"
inputMode="numeric"
inputStyles={{ textAlign: "center" }}
/>
)}

<button onClick={showOtp ? handleVerifyCode : handleSendCode}>
{showOtp ? "Verify" : "Continue"}
</button>
</div>
);
}

KeybanInput & session

Programmatic focus on a KeybanInput

Even though the value lives in an iframe, the focus API works through a forwarded ref.

import { KeybanInput, KeybanInputRef } from "@keyban/sdk-react";
import { useRef } from "react";

export default function FocusExample() {
const emailRef = useRef<KeybanInputRef>(null);
const passwordRef = useRef<KeybanInputRef>(null);

return (
<div>
<KeybanInput ref={emailRef} name="email" type="email" />
<KeybanInput ref={passwordRef} name="password" type="password" />

<button onClick={() => emailRef.current?.focus()}>Focus Email</button>
<button onClick={() => passwordRef.current?.focus()}>Focus Password</button>
</div>
);
}

Style a KeybanInput

Two style targets, easy to mix up:

PropApplied toUse it for
inputStylesThe input inside the iframeFont, text alignment, background color of the actual field
style / classNameThe iframe container in your DOMBorder, padding, border-radius around the field
import { KeybanInput } from "@keyban/sdk-react";

export default function StyledInputs() {
return (
<div>
<KeybanInput
name="otp"
inputMode="numeric"
inputStyles={{
textAlign: "center",
fontSize: "24px",
backgroundColor: "#f5f5f5",
color: "#333",
}}
style={{
border: "2px solid #007bff",
borderRadius: "8px",
padding: "12px",
}}
/>

<KeybanInput
name="email"
type="email"
inputStyles={{ fontSize: "16px", color: "#2c3e50" }}
className="custom-input-class"
/>
</div>
);
}

Sign a user out

import { useKeybanAuth } from "@keyban/sdk-react";

export default function UserProfile() {
const { user, signOut } = useKeybanAuth();

if (!user) return null;

return (
<div>
<p>Welcome, {user.email}!</p>
<button onClick={() => signOut()}>Sign Out</button>
</div>
);
}

Update user metadata

import { useKeybanAuth } from "@keyban/sdk-react";
import { useState } from "react";

export default function UpdateProfile() {
const { user, updateUser, isLoading } = useKeybanAuth();
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);

const handleUpdate = async () => {
setError(null);
setSuccess(null);
if (!user) return;
try {
await updateUser({ name: "New Name" });
setSuccess("Profile updated successfully!");
} catch (err) {
setError("Failed to update profile. Please try again.");
console.error(err);
}
};

return (
<div>
{error && <div style={{ color: "red" }}>{error}</div>}
{success && <div style={{ color: "green" }}>{success}</div>}
<p>Current Name: {user?.name || "Not set"}</p>
<button type="button" onClick={handleUpdate} disabled={isLoading}>
{isLoading ? "Updating..." : "Update Name"}
</button>
</div>
);
}

Handle session expiration

Sessions expire after a period of inactivity. When a session expires, the user is silently logged out. Detect this via useKeybanAuth() and redirect to your login page.

import { useKeybanAuth } from "@keyban/sdk-react";
import { useNavigate } from "react-router-dom";
import { useEffect } from "react";

export function RequireAuth({ children }: { children: React.ReactNode }) {
const { isAuthenticated, isReady } = useKeybanAuth();
const navigate = useNavigate();

useEffect(() => {
if (isReady && !isAuthenticated) {
// Session expired or never authenticated.
// Redirect to login, preserving the current path for post-login redirect.
const nextPath = window.location.pathname;
navigate(`/login?next=${encodeURIComponent(nextPath)}`);
}
}, [isReady, isAuthenticated, navigate]);

if (!isReady) return <div>Loading...</div>;
if (!isAuthenticated) return null;

return <>{children}</>;
}

On the login page, after a successful sign-in, read the next parameter and redirect:

const params = new URLSearchParams(window.location.search);
const nextPath = params.get("next") || "/";
navigate(nextPath);

Error handling

Keyban Auth surfaces all failures as AuthError instances. Use the code field to branch on specific error types; use message only for logging or debugging (it is human-readable but unstable across SDK versions).

Supported error codes:

#AuthErrorCodecode valueDefault message
1CancelledAUTH_CANCELLEDAuth flow cancelled by user
2InvalidPhoneNumberAUTH_INVALID_PHONE_NUMBERInvalid phone number
3InvalidSessionAUTH_INVALID_SESSIONInvalid session
4LoginFailedAUTH_LOGIN_FAILEDLogin failed
5LogoutFailedAUTH_LOGOUT_FAILEDLogout failed
6NetworkErrorAUTH_NETWORK_ERRORNetwork error
7OtpExpiredAUTH_OTP_EXPIREDOTP code expired
8OtpInvalidAUTH_OTP_INVALIDOTP code invalid
9RateLimitedAUTH_RATE_LIMITEDToo many attempts, please try again later
10UpdateUserFailedAUTH_UPDATE_USER_FAILEDUpdate user failed

AuthErrorCode.Cancelled

  • code value: AUTH_CANCELLED
  • Default message: Auth flow cancelled by user
  • When raised: The user cancelled the auth flow (closed an OAuth popup or aborted an OTP step).
  • Possible cause: Intentional user action. Treat as silent — do not show an error toast; just re-arm the sign-in UI.

AuthErrorCode.InvalidPhoneNumber

  • code value: AUTH_INVALID_PHONE_NUMBER
  • Default message: Invalid phone number
  • When raised: The provided phone number is not a valid E.164 number.
  • Possible cause: User typo or wrong calling code. Show a field-level error and let the user retry.

AuthErrorCode.InvalidSession

  • code value: AUTH_INVALID_SESSION
  • Default message: Invalid session
  • When raised: No valid session was found for the current request.
  • Possible cause: Session expired, token revoked, or no sign-in performed yet. Redirect the user to sign-in.

AuthErrorCode.LoginFailed

  • code value: AUTH_LOGIN_FAILED
  • Default message: Login failed
  • When raised: A sign-in attempt failed for a generic or unmapped reason.
  • Possible cause: Invalid credentials, server-side rejection, or fallback when no more specific code applies.

AuthErrorCode.LogoutFailed

  • code value: AUTH_LOGOUT_FAILED
  • Default message: Logout failed
  • When raised: The sign-out request failed against the API.
  • Possible cause: Network error or backend session-store unavailable. Note: if signOut throws, the local token is NOT cleared automatically — the caller decides whether to clear it explicitly.

AuthErrorCode.NetworkError

  • code value: AUTH_NETWORK_ERROR
  • Default message: Network error
  • When raised: The auth request could not reach the backend.
  • Possible cause: Offline device, DNS failure, or fetch aborted by the browser. Surface a "check your connection" message.

AuthErrorCode.OtpExpired

  • code value: AUTH_OTP_EXPIRED
  • Default message: OTP code expired
  • When raised: The OTP code was rejected because its TTL has elapsed.
  • Possible cause: User waited too long after receiving the code. Prompt to resend a new OTP.

AuthErrorCode.OtpInvalid

  • code value: AUTH_OTP_INVALID
  • Default message: OTP code invalid
  • When raised: The submitted OTP code does not match the one sent to the user.
  • Possible cause: Typo or stale code from a previous send. Let the user retry; show a "resend" affordance after repeated failures.

AuthErrorCode.RateLimited

  • code value: AUTH_RATE_LIMITED
  • Default message: Too many attempts, please try again later
  • When raised: Too many auth attempts within the rate-limit window.
  • Possible cause: Excessive OTP sends or verifies, or backend throttling (HTTP 429). Back off and retry later.

AuthErrorCode.UpdateUserFailed

  • code value: AUTH_UPDATE_USER_FAILED
  • Default message: Update user failed
  • When raised: A profile update against the user record failed.
  • Possible cause: Validation error, permission denied, or network error. Display the cause and allow retry.

Pattern for error handling:

async function handleSignIn(method: string) {
try {
await signIn({ type: method });
} catch (err) {
// err is an AuthError with a `code` field
if (err && typeof err === "object" && "code" in err) {
const code = (err as { code: string }).code;
switch (code) {
case "AUTH_INVALID_PHONE_NUMBER":
setFieldError("phone", "Invalid phone number");
break;
case "AUTH_OTP_EXPIRED":
case "AUTH_OTP_INVALID":
setFieldError("otp", "Code is invalid or expired — request a new one");
break;
case "AUTH_RATE_LIMITED":
setError("Too many attempts. Please wait a minute and try again.");
break;
case "AUTH_NETWORK_ERROR":
setError("Check your connection and try again.");
break;
case "AUTH_CANCELLED":
// Silent — user cancelled (closed OAuth popup, aborted OTP step).
break;
default:
setError(`Sign-in failed: ${(err as Error).message}`);
}
} else {
setError("Unexpected error");
}
}
}

Backend error format: HTTP errors (4xx, 5xx) are returned as RFC 7807 Problem Details. See the API reference for the schema. All Keyban Auth endpoints expose type, title, status, detail, and optionally instance fields.

Auto-generated catalog: the table above is rebuilt from the AuthErrorCode enum in sdk/apps/sdk-client/src/api/errors/auth-error.ts by tools/generate-error-catalog.ts. A pre-commit hook fails the commit if the doc drifts from the source.

Account linking

An account can be authenticated via multiple methods on the same email address.

Google auto-linking:

When you enable Google as a sign-in method and a user later signs in with Google on an email they already have an account for, Keyban Auth automatically links them. Both Email OTP (or Phone OTP) and Google then work on the same account with zero user friction.

Your application does not need to do anything special — the session reflects the current user regardless of which method they used to sign in.

Auth0 with other methods:

Single-method Auth0 deployments recommended

Mixing Auth0 with other sign-in methods on the same email is not yet officially supported. For organizations using Auth0, we recommend enabling Auth0 as the only sign-in method until multi-method support is rolled out.

Best practices

Never bypass KeybanInput

Plain HTML inputs leak the value into the host application's React tree and break the iframe security boundary. There is no exception — every field that captures email, phone, OTP or password must be a KeybanInput.

PracticeWhat it means
Consistent input namessendOtp / signIn reference inputs by name (emailInputName, phoneInputName, otpInputName); the form names must match exactly.
Right type and inputModeMobile keyboards adapt to these — type="tel" for phone, inputMode="numeric" for OTP, type="email" for email.
try/catch + visible errorAll failures surface as exceptions — see the error handling section for code patterns and which errors to expect.
Disable on isLoadingThe only signal that a previous call is still in flight. Without it, double-submits send a second OTP and trigger throttling.
Guard with config[method].enabledAdmin can flip a method off at any time — the UI should disappear, not crash.
Accessible markupEvery KeybanInput needs a <label>; keep the form keyboard-navigable; use semantic <form> / <button>.
Empty-config fallbackA fresh organization may have zero methods enabled — render a fallback rather than an empty form.

See also