Authentication recipes
Task-oriented snippets that go beyond the Email OTP tutorial. Pick a recipe, drop the snippet in.
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
Phone OTP, Google OAuth, Auth0 enterprise SSO — drop-in components for each method.
Adaptive UI
Render only the methods enabled by the organization, fall back gracefully when none are.
Form integrations
Wire KeybanInput into Material UI, MuiTelInput, or any field-with-slot component.
KeybanInput & session
Programmatic focus, custom styling, sign-out, and user metadata updates.
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.
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:
| Prop | Applied to | Use it for |
|---|---|---|
inputStyles | The input inside the iframe | Font, text alignment, background color of the actual field |
style / className | The iframe container in your DOM | Border, 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:
| # | AuthErrorCode | code value | Default message |
|---|---|---|---|
| 1 | Cancelled | AUTH_CANCELLED | Auth flow cancelled by user |
| 2 | InvalidPhoneNumber | AUTH_INVALID_PHONE_NUMBER | Invalid phone number |
| 3 | InvalidSession | AUTH_INVALID_SESSION | Invalid session |
| 4 | LoginFailed | AUTH_LOGIN_FAILED | Login failed |
| 5 | LogoutFailed | AUTH_LOGOUT_FAILED | Logout failed |
| 6 | NetworkError | AUTH_NETWORK_ERROR | Network error |
| 7 | OtpExpired | AUTH_OTP_EXPIRED | OTP code expired |
| 8 | OtpInvalid | AUTH_OTP_INVALID | OTP code invalid |
| 9 | RateLimited | AUTH_RATE_LIMITED | Too many attempts, please try again later |
| 10 | UpdateUserFailed | AUTH_UPDATE_USER_FAILED | Update user failed |
AuthErrorCode.Cancelled
codevalue: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
codevalue: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
codevalue: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
codevalue: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
codevalue: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
codevalue: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
codevalue: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
codevalue: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
codevalue: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
codevalue: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:
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
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.
| Practice | What it means |
|---|---|
| Consistent input names | sendOtp / signIn reference inputs by name (emailInputName, phoneInputName, otpInputName); the form names must match exactly. |
Right type and inputMode | Mobile keyboards adapt to these — type="tel" for phone, inputMode="numeric" for OTP, type="email" for email. |
try/catch + visible error | All failures surface as exceptions — see the error handling section for code patterns and which errors to expect. |
Disable on isLoading | The 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].enabled | Admin can flip a method off at any time — the UI should disappear, not crash. |
| Accessible markup | Every KeybanInput needs a <label>; keep the form keyboard-navigable; use semantic <form> / <button>. |
| Empty-config fallback | A fresh organization may have zero methods enabled — render a fallback rather than an empty form. |
See also
- Conceptual overview, security model, method comparison: Keyban Auth — concepts.
- Step-by-step Email OTP walkthrough: Authenticate with Email OTP.
- Auto-generated SDK reference:
useKeybanAuthandKeybanInput.