@ciromaciel/auth-react
Passwordless authentication for React: a code arrives by email, and it becomes a JWT session.
There is one way in. No password, no sign-up screen, no reset flow — the account is created and verified the first time a code is presented. Social sign-in is available when the application owner enables a provider.
Installation
bun add @ciromaciel/auth-react
Peer dependencies: react, react-dom, react-router-dom and zustand. The pre-built screens
also need @mantine/core, @mantine/form, @mantine/hooks and @tabler/icons-react — all
optional if you build your own UI from the hooks.
Quick start
import { AuthProvider, Protect, SignIn, SignedIn, SignedOut, SignInButton } from '@ciromaciel/auth-react'
function App() {
return (
<AuthProvider apiKey="your-api-key">
<Routes>
<Route path="/login" element={<SignIn />} />
<Route element={<Protect redirectTo="/login" />}>
<Route path="/" element={<Home />} />
</Route>
</Routes>
</AuthProvider>
)
}
function Header() {
return (
<header>
<SignedIn>
<UserMenu />
</SignedIn>
<SignedOut>
<SignInButton />
</SignedOut>
</header>
)
}
AuthProvider takes apiKey (required), apiUrl, internal, onError, appearance and
optimistic. In internal mode the API key is not required — that is for same-origin applications.
With optimistic (the default, since 1.10.0) a stored session that has not expired opens the screen
at once, and Auth confirms it in the background: a session Auth refuses signs out as soon as it
answers. Pass optimistic={false} to keep the loading screen until Auth answers. An impersonated
session always waits.
Signing in
Two steps, and nothing else:
import { useSignIn } from '@ciromaciel/auth-react'
const { requestCode, verifyCode, sending, verifying, error } = useSignIn()
await requestCode(email) // a code goes out by email
await verifyCode(email, code) // the code becomes a session
Or use the ready-made screen:
<SignIn
authenticatedRedirect="/" // where to send someone who already has a session
onCodeSent={email => notify(`Code sent to ${email}`)}
onSuccess={(user, { result, redirectHandled }) => notify(`Welcome ${user?.email}`)}
onError={(error, { isShownOnCard }) => {
// A wrong, expired or exhausted code is already explained on the card
if (!isShownOnCard) notify(error.message) // error.code carries the stable identifier
}}
/>
onSuccessreceives the user first; the raw API response isresult.redirectHandledistruewhen a?redirect=in the URL was already applied. The component honours it by default, validating the destination against the API origin and the current one;redirectOriginsadds domains andhandleRedirect={false}hands control back to the app.- A wrong code fails with
error.details.attemptsLeft. Five wrong tries destroy the request and the person asks for a new code. - On the code step the card explains every failure itself: how many tries are left, or, once the
code is exhausted or expired, a button that sends a new one.
onErrorstill fires, with a second argument{ step: 'request' | 'verify', isShownOnCard }; skip your own notification whenisShownOnCardistrue, or the person reads the same failure twice.
Social sign-in
<SignIn /> renders the provider buttons on its own: socialLogin defaults to 'auto', which
shows whatever the application owner enabled and nothing at all when they enabled none.
<SignIn /> {/* buttons appear when a provider is live */}
<SignIn socialLogin={false} /> {/* emailed code only */}
Building your own screen? getSocialProviders() lists what is live,
startSocialSignIn(provider, { redirect }) leaves for the provider, and consumeSocialToken()
reads the token the callback leaves in the URL fragment — AuthProvider already calls that one
for you. startSocialLink(), unlinkSocialProvider() and getLinkedProviders() manage the
connections of an account that is already signed in.
Recent accounts
<SignIn /> remembers the accounts that signed in on this browser and, on the next visit, shows
them before the email field. One click on an account sends the code (or goes back to the provider
that account used last time) and opens the code step. With nothing saved, the screen is the plain
email form.
- An account is saved only after a session exists: the code confirmed, or the provider's token back. A mistyped email never becomes a suggestion.
- The list keeps the five most recent accounts. Each entry holds the email, the way in and the time of the last sign-in.
- Every app of the same application shares the list, even across origins: the Auth worker keeps
it in an HttpOnly cookie on its own host and answers only to the origins the application
allows. A copy in
localStorage(auth:recent-accounts) renders at once while the shared list loads, and stands in when the worker cannot be reached. - Signing out keeps the list. "Gerenciar" on the screen removes an account from every app of the application; nothing else about that account changes.
<SignIn /> {/* shows and saves recent accounts */}
<SignIn recentAccounts={false} /> {/* shared computers: neither shows nor saves */}
Building your own screen? fetchRecentAccounts(), saveRecentAccount(method) and
deleteRecentAccount(email) talk to the shared list; listRecentAccounts(),
rememberAccount(email, method), forgetAccount(email) and adoptRecentAccounts(list) manage
the local copy.
Several accounts, one browser
A person with two accounts does not sign out of one to enter the other. Every account that signs in on a browser stays signed in, and switching between them takes one click and no code.
- Nothing to wire.
<UserInformation />shows the other accounts under "Outras contas" and an "Adicionar conta" row; with more than one account, the exit becomes "Sair desta conta" and "Sair de todas as contas".<SignIn />offers the accounts still signed in as rows that enter with "Conectada · entra sem código", above the recent ones. - Adding an account opens your sign-in route with
?mode=add.<GuestOnly>and<SignIn>let a signed-in person through in that mode, and the current account stays signed in. An app whose sign-in sits inside a page shows it whenisAddingAccount()is true, and passes that page assignInUrl. - Switching is an identity switch: everything your app keeps in storage for the previous
account is wiped (as with an impersonation, see
IDENTITY_CHANGED_EVENT), the other tabs follow, and the page lands onafterSwitchUrl(default/), because the address on screen may be a resource only the previous account can open. - One account is active per browser at a time; every request still carries one token, so your backend changes nothing.
- On the platform's zones the list lives behind an HttpOnly cookie. On your own domain the SDK
keeps the browser's secret in
localStorage(auth:browser), next to the session token.
<AuthProvider apiKey="pu_..." afterSwitchUrl="/" signInUrl="/auth/signin">
<UserInformation hasAccountSwitcher={false} /> {/* one account per browser */}
<AccountSwitcher onSignedOut={goToSignIn} /> {/* the same group, inside your own menu */}
const { accounts, otherAccounts, switchAccount, addAccount, signOutAccount, signOutAll } = useAccounts()
The owner's timeline
Nothing to wire: with AuthProvider in place, the owner of the application sees, on each user's
page in the Auth panel, where the person came from and what they did.
- The visit: the referrer from another site,
utm_*, an ad's click id and the landing page, kept on the first screen and sent right after a sign-in. - The screens: each route opened, the path only, ids collapsed, at most once per path every 5 minutes.
- The actions: every write your app sends to its own API (POST, PUT, PATCH, DELETE) with the person's token, once it succeeds. Only the method and the address travel, never the body or the answer. Writes to Auth itself are left out.
<AuthProvider activity={false}>turns screens and actions off. For a richer label, or an action with no browser (a CLI, a job), the backend can callreportActivityfrom@ciromaciel/contract/activitywith the application's secret key.
The account card
<UserInformation /> is what opens from the footer of a sidebar: who is signed in, in which
organization and on which plan, then one list of actions with "Sair" last.
<UserInformation
user={user}
signOut={signOut}
onAccountClick={openAccount}
onBillingClick={openBilling}
items={[{ id: 'docs', label: 'Documentação', icon: IconFileText, onClick: openDocs }]}
plan={{ name: 'Pro', used: 8, limit: 9, unit: 'projetos', canUpgrade: true, onClick: openPlans }}
organization={{ name: 'Acme', role: 'owner' }}
/>
-
The title is the user's name, or the email when there is none — never both saying the same thing.
-
itemsare your app's own rows, passed as data ({ id, label, icon, onClick }), shown in their own group after billing. The component takes no JSX, so every app's card keeps the same shape. A row may adddescription(one line saying what it does) andtone: 'danger'(red, for what cannot be undone);itemsLabeltitles the group:itemsLabel="Seus dados" items={[ { id: 'export', label: 'Exportar', description: 'Uma cópia de tudo, em JSON', icon: IconDownload, onClick: exportAll }, { id: 'erase', label: 'Apagar tudo', description: 'Sem volta', tone: 'danger', icon: IconTrash, onClick: eraseAll }, ]} -
planandorganizationdraw the context strip; leave them out and it is not drawn.limit: nullhides the usage bar. -
The strip informs; "Assinatura" is the way to billing. The strip adds a "Fazer upgrade" invite only when
canUpgradeistrue(there is a plan above this one) and usage is at 80% of the limit or more. At the limit, the bar and the count turn red and "Limite atingido" appears on the badge's line.plan.onClickis what the invite opens;actionLabelrenames it. -
The footer reads "Protegido por Auth" with a "Termos" link to the same terms the sign-in cites.
termsUrlpoints it elsewhere,termsUrl={null}drops the link, andbranded={false}removes the footer.
The account screen
<UserProfile /> is what "Conta" on the account card opens: the photo, the name and the email;
the ways in; and the devices with an open session.
<UserProfile opened={opened} onClose={close} />
- Every text defaults to Portuguese.
labelschanges a word (labels={{ edit: 'Editar' }}); a panel does not need it to translate the screen. - Name and photo are edited in place: "Alterar" opens the field on the same row, Enter saves, Esc cancels and leaves the screen open. The email does not change here, and says why: it is where the sign-in code arrives.
- "Formas de entrar" lists the emailed code, which always works, and the providers the
application enabled, with "Conectar" and "Desconectar". With none enabled, the group is not
drawn.
showSignInMethods={false}hides it. - The sessions sit on one line ("2 sessões abertas", and which one is this device); a click opens the list right below. The current device comes first and cannot be ended from here — that is "Sair". "Encerrar as outras" asks before ending them.
- Failures are shown on the screen, next to what failed.
onErrorstill receives the error, with{ section, isShownOnScreen: true }; do not show it a second time.
API
Components
| Component | Description |
|---|---|
<SignIn /> | The whole way in: asks for the email, then takes the code |
<UserProfile /> | The account screen: profile, ways in, open sessions |
<UserInformation /> | The account card: who, where, which plan, then the actions |
<SocialButtons /> | Sign-in buttons for the providers the application enabled |
<AuthCard /> | The card shell the screens are built on |
<Wordmark /> | Application wordmark |
Routing
| Component | Description |
|---|---|
<Protect /> | Route wrapper; redirects to redirectTo without a session |
<GuestOnly /> | The counterpart: keeps a signed-in person off the login screen |
Conditional rendering
| Component | Description |
|---|---|
<SignedIn> | Renders children only when authenticated |
<SignedOut> | Renders children only when NOT authenticated |
<AuthLoading> | Renders children while auth is loading |
<AuthLoaded> | Renders children once auth has loaded |
Unstyled buttons
| Component | Description |
|---|---|
<SignInButton /> | Navigates to sign-in page |
<SignOutButton /> | Signs out the user |
Hooks
const { user, loading, error, isAuthenticated, requestCode, verifyCode, signOut } = useAuth()
const { user, updateProfile, loadingUpdateProfile } = useUser()
const { requestCode, verifyCode, sending, verifying, error } = useSignIn()
const { getSession, user, setUser } = useSession()
const { sessions, listSessions, revokeSession, revokeOtherSessions } = useSessions()
const loadingStates = useAuthLoading()
const impersonation = useImpersonation() // the borrowed session, or null
const { accounts, otherAccounts, switchAccount, addAccount } = useAccounts() // the accounts of this browser
const logo = useApplicationLogo()
const signOut = useSignOut()
const checkToken = useCheckToken()
Direct SDK calls
For code outside React, the HTTP layer is exported too: configure, requestCode, verifyCode,
pollCode, signOut, refreshToken, getSession, listSessions, revokeSession,
revokeOtherSessions, updateProfile, getApplicationInfo, isAuthenticated, getCurrentUser,
decodeJWT, setStoredToken, and the social helpers listed above.
Features
- Passwordless — an emailed code, for people and agents alike; no password to leak
- JWT sessions with automatic refresh
- JWKS signature verification via
/.well-known/jwks.json - Cross-tab sync — sign out in one tab, every tab follows
- Route protection through
<Protect />and<GuestOnly /> - SSR friendly
Links
MIT © Ciro Cesar Maciel