@useauthio/react
v0.3.0
Published
React SDK for Authio: hooks, gates, and sign-in helpers for pure SPAs.
Downloads
471
Readme
@useauthio/react
Part of Authio Lobby — Authio's drop-in passwordless authentication. Learn more at https://authio.com/products/lobby.
React SDK for Authio — hooks, gates, and sign-in helpers for pure
SPAs that talk directly to the identity API via CORS. If you have a
Next.js or other BFF, reach for @useauthio/nextjs
instead.
Recent additions
@useauthio/widgetsships. Drop-in<AuthioSSOConnectionWidget>and<AuthioDirectorySyncWidget>components let your customers' IT admins configure SAML / OIDC and SCIM directories from inside your own product. Mint the JWT from your BFF; embed the widget anywhere in yourSignedIntree. Seeauthio_widgetsand the docs:/widgets/overview,/widgets/tokens,/sdks/react.JWT carries
roles+permissionsnow. Every customer-session access token in a customer-tenant project ships the new claims (/concepts/roles-and-permissions). The hooks (useAuthio,useUser, etc.) keep their existing shape; the new claims are exposed onsession.claimsfor any code that wants to gate UI on roles or permissions.kind: "widget"JWTs are explicitly refused on every customer surface. Embedding the widget bundle alongside@useauthio/reactcomposes safely; the widget bearer is never confusable with the standard customer session JWT.<AuthioProvider>+useAuthio()for session state.<SignedIn>/<SignedOut>/<RedirectToSignIn>declarative gates.signInWithMagicLink()andsignInWithPasskey()as top-level helpers — call them straight from your form submit handler.Silent refresh with visibility-aware scheduling and exponential backoff on failure.
JWT signature verification (EdDSA-pinned) via
@useauthio/node'sJwtVerifier. Tokens that fail verification are dropped.SSR-safe — every hook handles
typeof window === "undefined".Dual ESM + CJS build via tsup; zero runtime deps beyond
@useauthio/node.
5-minute integration
Install:
npm install @useauthio/react
# or: pnpm add @useauthio/reactWrap your app once at the root:
// src/main.tsx
import React from "react";
import ReactDOM from "react-dom/client";
import { AuthioProvider } from "@useauthio/react";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root")!).render(
<AuthioProvider
apiUrl={import.meta.env.VITE_AUTHIO_API_URL}
projectId={import.meta.env.VITE_AUTHIO_PROJECT_ID}
>
<App />
</AuthioProvider>,
);Drop gates and a magic-link form:
import {
SignedIn,
SignedOut,
signInWithMagicLink,
useAuthio,
} from "@useauthio/react";
export default function App() {
const { user, signOut } = useAuthio();
return (
<>
<SignedOut>
<MagicLinkForm />
</SignedOut>
<SignedIn>
<p>Hello, {user?.email}!</p>
<button onClick={() => void signOut()}>Sign out</button>
</SignedIn>
</>
);
}
function MagicLinkForm() {
const [email, setEmail] = React.useState("");
return (
<form
onSubmit={async (e) => {
e.preventDefault();
await signInWithMagicLink({
apiUrl: import.meta.env.VITE_AUTHIO_API_URL,
projectId: import.meta.env.VITE_AUTHIO_PROJECT_ID,
email,
redirectUri: `${window.location.origin}/auth/callback`,
});
}}
>
<input value={email} onChange={(e) => setEmail(e.target.value)} />
<button>Send magic link</button>
</form>
);
}That's it. The provider runs a silent POST /v1/auth/refresh against
your BFF cookie at mount, schedules a renewal one minute before the
access JWT expires, and verifies every received token against the
identity API JWKS before trusting it.
A complete runnable example lives in examples/vite-react/.
Custom auth domains
The default signInUrl is the platform Lobby (https://lobby.authio.com/).
Enterprise customers with a branded auth host point signInUrl at that URL
instead — in Vite apps, typically via VITE_AUTHIO_SIGN_IN_URL:
<AuthioProvider
apiUrl={import.meta.env.VITE_AUTHIO_API_URL}
projectId={import.meta.env.VITE_AUTHIO_PROJECT_ID}
signInUrl={
import.meta.env.VITE_AUTHIO_SIGN_IN_URL ?? "https://lobby.authio.com/"
}
/>DNS setup for a vanity hostname (e.g. auth.acme.com) is dashboard-side:
CNAME your auth host to cname.authiodns.com. The SDK does not reference
that CNAME at runtime — it only affects where end users sign in when you override
signInUrl. See Custom domains.
API reference
<AuthioProvider>
| Prop | Type | Default | Description |
|------|------|---------|-------------|
| apiUrl | string (required) | — | Identity API base URL, e.g. "https://identity.authio.com" (legacy alias auth-api.authio.com). |
| projectId | string (required) | — | Environment ID (proj_…; API field project_id). Sent as X-Authio-Project on every call. |
| storage | "memory" \| "localStorage" \| "sessionStorage" \| "none" | "memory" | Access-token storage backend. Refresh tokens are never JS-accessible regardless of this setting. |
| refreshLeadSeconds | number | 60 | How many seconds before exp to schedule the silent refresh. |
| idleRefresh | "defer" \| "always" | "defer" | Under a project/org inactivity timeout (sessionPolicy.idleTimeoutMin > 0), "defer" holds the timer-driven refresh until the user next interacts, so an idle tab doesn't keep the session alive. Projects without an inactivity policy are unaffected. "always" restores unconditional timer refresh. |
| onTelemetryEvent | (event: AuthioTelemetryEvent) => void | — | Sink for SDK telemetry (refresh outcomes, sign-in starts/completes, token verification). No phone-home by default. |
| fetch | typeof fetch | globalThis.fetch | DI for tests / custom transports. |
| signInUrl | string | https://lobby.authio.com/ | Lobby (hosted UI) sign-in URL. Set to your branded auth host (e.g. https://auth.acme.com/) when using a custom domain — see Custom auth domains below. |
| jwtIssuer | string | apiUrl | JWT iss claim to require during verification. |
| jwtAudience | string | "authio" | JWT aud claim to require during verification. |
| verifyToken | AuthioTokenVerifier | JwtVerifier-based | Override the verification step (testing / custom JWKS). |
| skipInitialRefresh | boolean | false | Skip the bootstrap refresh and start in unauthenticated. |
useAuthio()
{
user: AuthioUser | null;
status: "loading" | "authenticated" | "unauthenticated";
accessToken: string | null;
sessionPolicy: SessionPolicy | null; // { idleTimeoutMin, absoluteMaxMin, accessTokenTtlMin } in minutes, 0 = no limit
getAccessToken: () => Promise<string | null>;
signIn: (opts?: { returnTo?: string }) => void;
signOut: () => Promise<void>;
refresh: () => Promise<boolean>;
}sessionPolicymirrors thesession_policyobject auth-core returns on every session envelope — the effective inactivity / maximum-session / access-token limits for the current project or organization.nulluntil the first successful refresh or sign-in, and on auth-core versions that predate the field.getAccessToken()returns the current token if it's more than 10s fromexp, otherwise transparently triggers a refresh and returns the new value (ornullon failure). Use it as yourAuthorizationsource for outbound API calls.signIn()navigates the tab to Lobby (signInUrl). When identity API supports it, the SDK mints a short-livedctxtoken soproject_idandredirect_uriare not exposed in the URL; it falls back to legacy?project_id=…&redirect_uri=…query params otherwise.signOut()best-effort POSTs to/v1/auth/sign-out, then clears local state regardless of the upstream result.refresh()runs one refresh attempt and resolvestrueon success.
useAuthioRequired({ fallback, redirectTo })
Convenience hook for "this view requires a session". While the
provider is loading or the session is unauthenticated, returns
fallback via the gate field. When unauthenticated, kicks off a
redirect to redirectTo (or signIn() if omitted).
function Dashboard() {
const { user, gate } = useAuthioRequired({ fallback: <Spinner /> });
if (gate) return gate;
return <h1>Hello {user!.email}</h1>;
}Gates
<SignedIn>— renders children only when status is"authenticated".<SignedOut>— renders children only when status is"unauthenticated".<RedirectToSignIn returnTo="/dashboard" />— declarative; callssignIn({ returnTo })on mount.
All three render null while status is "loading" so authenticated-only
UI never flashes during the initial refresh.
signInWithMagicLink({ apiUrl, projectId, email, redirectUri, signal?, fetch? })
Top-level helper, not hook-bound — drop it directly into your
form submit handler. POSTs /v1/auth/magic-link/send. Throws
AuthioError on any failure so you can render err.code /
err.message directly. Returns void on success — the magic link is
now in the user's inbox.
signInWithPasskey({ apiUrl, projectId, email?, signal?, fetch? })
Runs the full WebAuthn assertion ceremony against
/v1/auth/passkey/login/options + /v1/auth/passkey/login/verify.
Returns { accessToken, refreshToken?, user }. Throws AuthioError
with codes not_in_browser, webauthn_unsupported,
webauthn_cancelled, or any identity API error code on a step failure.
The returned refreshToken is included for completeness but the SDK
itself never persists it — identity API also sets the refresh cookie via
Set-Cookie and that's the canonical home for it.
Passkey management (/v1/me/passkeys)
After signup, let users add and manage passkeys on the sign-in origin or from your SPA:
import {
addPasskey,
listPasskeys,
renamePasskey,
revokePasskey,
enrollPasskey,
usePasskeys,
PasskeyManager,
} from "@useauthio/react";
const token = await getAccessToken();
await addPasskey({
apiUrl: "https://identity.authio.com",
projectId: "proj_123",
accessToken: token!,
email: user.email!,
});addPasskey— inline WebAuthn on the current origin (use when your app runs on the same host as the hosted sign-in UI / customauth.domain).enrollPasskey— redirects to hosted UI?mode=add_credentialwhen the SPA origin differs from the WebAuthn RP ID.listPasskeys/renamePasskey/revokePasskey— CRUD against/v1/me/passkeyswith a sessionaccessToken.<PasskeyManager />+usePasskeys()— drop-in UI for customer apps.
AuthioError
Re-exported from @useauthio/node. Thrown by every helper and the
provider on failure. Shape: { code, message, status, requestId? }.
When to use this vs. @useauthio/nextjs
| You have… | Use |
|-----------|-----|
| A pure SPA (Vite, CRA, Webpack) talking directly to identity API via CORS | @useauthio/react (this package) |
| A Next.js app with a BFF (/api/auth/* route handlers) | @useauthio/nextjs |
| A Remix / TanStack Start / Astro app | @useauthio/react for the client; your framework's loader/action helpers for the server |
The reason for the split: @useauthio/nextjs lives in the BFF cookie
world — it owns authio_session + authio_refresh HttpOnly cookies
on your origin and the SPA sees only useUser()-style hooks
pointed at /api/auth/me. @useauthio/react lives in the CORS world —
your SPA fetches identity.authio.com directly with
credentials: include, and the refresh cookie is set on the
identity API origin. Both end up with the same DX in components, but
the network shape and the threat surface differ.
CSP guidance
The provider issues fetch to apiUrl. Configure your Content
Security Policy so the browser allows it:
Content-Security-Policy:
default-src 'self';
connect-src 'self' https://identity.authio.com;
script-src 'self';
style-src 'self' 'unsafe-inline';If you've moved identity API to a custom domain (e.g.
auth.acme.com), allowlist that host instead. The hosted sign-in UI
itself is reached via a top-level navigation (window.location.assign),
which is not subject to connect-src.
Security considerations on token storage
| Mode | Where the access token lives | XSS exposure | Persistence |
|------|------------------------------|--------------|-------------|
| "memory" (default) | A closure inside <AuthioProvider> | Same as your app code | Cleared on hard reload |
| "sessionStorage" | window.sessionStorage | Readable by any script on the same origin | Cleared when tab closes |
| "localStorage" | window.localStorage | Readable by any script on the same origin | Survives reload |
| "none" | Nowhere — refetched on every page load | None | None |
Default is "memory" for a reason: a single XSS escape gets the
attacker your access token if it's in localStorage / sessionStorage.
The 15-minute access-token TTL caps the blast radius, but a
sufficiently-clever attacker can chain that into a refresh-rotation.
If you need persistence-across-reloads on a SPA (no BFF cookie),
pick "sessionStorage" over "localStorage" — the blast radius
is one tab. If you have a BFF, you don't need either; the memory
mode plus the refresh cookie is enough.
The refresh token is NEVER stored in JS-accessible storage,
regardless of which storage mode you choose. It rides only the
HttpOnly cookie that identity API sets at /v1/auth/refresh and
/v1/auth/passkey/login/verify. getAccessToken() and the silent
refresh path both call those endpoints with credentials: include
to pick up the rotated token without ever surfacing the refresh
token to your code.
CSRF guidance
If your SPA is hosted on a different origin than identity API (e.g.
app.acme.com calling identity.authio.com):
- Identity API's CORS allowlist must include your SPA origin. The identity API operator adds it via the project settings.
- The provider always uses
credentials: includeon/v1/auth/refreshand/v1/auth/sign-out, so the browser sends the refresh cookie on those calls. - Identity API enforces a SameSite=None refresh cookie when the SPA origin differs from the cookie origin, plus the per-project CORS origin check — that's the line of defence against CSRF on the refresh endpoint itself.
If your SPA is hosted on the SAME parent domain as identity API (e.g.
both under *.acme.com), the refresh cookie is a SameSite=Lax
first-party cookie and the cross-origin check is implicit.
Locate (GPS + custom actions)
Capture GPS in the browser, then POST to your backend. The backend calls
management API with a secret key (sk_live_… / sk_test_…) — never expose
sk_ in the browser:
import { captureClientLocation } from "@useauthio/react";
const gps = await captureClientLocation({ enableHighAccuracy: true });
const result = await fetch("/api/locate/verify-wager", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ action: "wager_placed", client_location: gps }),
});Server-side, use @useauthio/node → authio.locate.verify({ action, client_location, user_id, … })
against https://manage.authio.com/v1/locate/verify.
React Native: @useauthio/react is browser-only. Use native geolocation
(expo-location or @react-native-community/geolocation), POST to your backend,
same proxy pattern. Full JB migration guide: authio_locate/docs/JB_MIGRATION_GUIDE.md.
License
MIT — see LICENSE.
