@tillstack/auth-react
Provider + hooks for SPAs and Next.js apps. Auto-refresh, OAuth fragment capture, MFA, passkeys, optional cookie-mode for HttpOnly sessions. Works with React 18+ and React 19.
Install
pnpm add @tillstack/auth-reactTillAuthProvider
Wrap your root once.
import { TillAuthProvider } from '@tillstack/auth-react'
<TillAuthProvider appId={process.env.NEXT_PUBLIC_TILLAUTH_APP_ID!}>
<App />
</TillAuthProvider>Props:
appId— your TillAuth public ID (tau_pub_…). Required.apiBase— override the API base. Defaults tohttps://auth.tilldev.dev.storagePrefix— storage key prefix. Defaults totau_.storage— custom storage. Defaults to localStorage in browser, in-memory otherwise.sessionMode—'local'(default) or'cookie'. See below.cookieProxyBase— whensessionMode='cookie', the base URL of your same-site proxy (typically/api/auth/*).
useSession · useUser · useAuthStatus
import { useSession, useUser, useAuthStatus } from '@tillstack/auth-react'
const session = useSession() // { access_token, user } | null
const user = useUser() // User | null
const status = useAuthStatus() // 'loading' | 'unauthenticated' | 'authenticated'User has { id, email, email_verified, display_name, mfa_enrolled }. The provider auto-refreshes the access token 60 seconds before expiry, so session.access_token is always current.
useSignIn
const { signIn, pending, error } = useSignIn()
async function submit() {
const r = await signIn(email, password)
if (r.ok) {
// session is set; redirect
} else if ('mfa_required' in r && r.mfa_required) {
// r.challenge_token — pass to useMfa().verify(...)
} else {
// r.error
}
}useSignUp
const { signUp, pending, error } = useSignUp()
await signUp({ email, password, display_name })
// On success: session set + verification email sent.useSignOut
const signOut = useSignOut()
await signOut() // Revokes the session, clears storage, transitions to unauthenticated.useMfa
For TOTP challenges after a primary signin.
const { verify, pending, error } = useMfa()
// challenge_token came back from useSignIn().signIn()
const r = await verify({ challenge_token, code })
if (r.ok) {
// session minted
}Passkeys
import {
passkeysSupported,
usePasskeys,
useRegisterPasskey,
useSignInWithPasskey,
} from '@tillstack/auth-react'
if (!passkeysSupported()) return null // unsupported browser
// List the user's passkeys.
const { passkeys, pending, refresh } = usePasskeys()
// Register a new one (must be authenticated).
const { register } = useRegisterPasskey()
await register({ label: 'MacBook · TouchID' })
// Sign in with a passkey (no email needed; discoverable-credential flow).
const { signIn } = useSignInWithPasskey()
const r = await signIn()
if (r.ok) {
// session set
}Session modes
Two ways to store and present the access token:
sessionMode: 'local' (default)
Tokens live in localStorage. The SDK sets Authorization: Bearer <access_token> on its own fetches. You're responsible for adding the same header to your own backend calls — read it from useSession().access_token.
XSS that runs in your app can steal the token. Mitigations: strict CSP, no third-party scripts, sanitize all user-supplied HTML.
sessionMode: 'cookie'
Tokens live in HttpOnly cookies set by a same-site proxy you run at cookieProxyBase (usually /api/auth on your own backend). The SDK does credentials: 'include' on every request; cookies ride along automatically.
Use this when XSS-driven token theft matters more than cross-origin convenience. The matching backend helper is expressCookieProxy / createFetchHandler in @tillstack/auth-node.
OAuth fragments
When the user returns from an OAuth provider, the URL looks like ?tau_session=<…>. The provider captures and consumes this on mount automatically, no extra wiring. Just make sure the redirect target renders <TillAuthProvider>.
Errors
TillAuthError is what the SDK throws on programmatic misuse. For typed runtime failures from signin/signup/etc., the hook returns a discriminated union and you branch on r.ok.
Server side: @tillstack/auth-node.