New:Microsoft Teams Notifications Are Now Available in Socket.Learn more
Get Started

@authlocker/react

Package Overview
Dependencies
Maintainers
1
Versions
4
Alerts
File Explorer

Advanced tools

Socket logo

Install Socket

Detect and block malicious and high-risk dependencies

Install

@authlocker/react

Headless React primitives for AuthLocker: one OTP state machine for email and SMS, social sign-in triggers, and an adapter seam so the same components run against a Better Auth plugin or your own route handler.

latest
npmnpm
Version
0.2.1
Version published
Maintainers
1
Created
Source

@authlocker/react

Headless React primitives for AuthLocker: one OTP state machine that serves both email and SMS, social sign-in triggers, and an adapter seam so the same components run against a Better Auth plugin today and your own route handler tomorrow.

No styles, no markup opinions, no runtime dependency but React. Every part carries data-state, so you can style the whole flow in plain CSS.

pnpm add @authlocker/react

Why there is an adapter at all

POST /api/v1/verify/* authenticates with your client secret, and AuthLocker sends no CORS headers, so a browser can never call it — not with a clever fetch, not at all. Every consumer needs a server of their own in front of it. That server is the only thing that differs between setups, so it is the only thing these components are parameterised by.

// Ships now — the Better Auth plugin.
import { createBetterAuthAdapter } from "@authlocker/react";
import { authClient } from "@/lib/auth-client";

export const adapter = createBetterAuthAdapter(authClient);
// The same contract against your own catch-all. See the note below.
import { createAuthLockerAdapter } from "@authlocker/react";

export const adapter = createAuthLockerAdapter({ basePath: "/api/authlocker" });

Wrap the tree once:

"use client";
import { AuthLockerProvider } from "@authlocker/react/client";

export function Providers({ children }: { children: React.ReactNode }) {
  return <AuthLockerProvider adapter={adapter}>{children}</AuthLockerProvider>;
}

Every hook and every part also takes an adapter prop, so one form can talk to a different backend than the rest of the app without a second provider.

createAuthLockerAdapter is written but its server is not. @authlocker/next and its /api/authlocker/[...path] handler are the next package. Until it lands, the four routes it expects are documented on the function itself and are a few lines each — proxy verify/start, verify/check and verify/resend with the HTTP Basic header, and redirect oauth/{provider}/start to the authorize endpoint. Forward AuthLocker's failure body unchanged: attemptsRemaining rides beside error, not inside it, and re-wrapping it costs the UI its attempt counter.

Email OTP

"use client";
import { EmailOtp } from "@authlocker/react/client";

export function SignInForm() {
  return (
    <EmailOtp.Root template="signin" onVerified={(v) => console.log(v.status)}>
      <EmailOtp.TargetField placeholder="you@example.com" />
      <EmailOtp.StartTrigger>Send code</EmailOtp.StartTrigger>

      <EmailOtp.CodeField />
      <EmailOtp.VerifyTrigger>Verify</EmailOtp.VerifyTrigger>

      <EmailOtp.ResendTrigger>
        {({ secondsRemaining }) =>
          secondsRemaining > 0 ? `Resend in ${secondsRemaining}s` : "Resend code"}
      </EmailOtp.ResendTrigger>

      <EmailOtp.Status>
        {({ state, verification, attemptsRemaining }) =>
          state === "awaiting_code"
            ? `We sent a code to ${verification?.to}. ${attemptsRemaining} tries left.`
            : null}
      </EmailOtp.Status>

      <EmailOtp.Error />
    </EmailOtp.Root>
  );
}

Otp.Root renders a <div>, the triggers render <button>s, the fields render <input>s. Nothing is hidden or shown for you — that is what data-state is for:

[data-authlocker-otp][data-state="idle"] .code-row { display: none }
[data-authlocker-otp][data-state="verified"] .form { display: none }
.resend[data-disabled] { opacity: .5; pointer-events: none }

Phone OTP

The same machine with channel: "sms", so PhoneOtp is a binding over Otp, not a second implementation. TargetField picks up type="tel" from the channel; the address must be E.164.

<PhoneOtp.Root>
  <PhoneOtp.TargetField placeholder="+15551234567" />
  <PhoneOtp.StartTrigger>Text me a code</PhoneOtp.StartTrigger>
  <PhoneOtp.CodeField />
</PhoneOtp.Root>

SMS requires a client claimed into a namespace; the anonymous DCR client gets SMS_REQUIRES_CLAIMED_CLIENT.

Social

import { SignInWith, SignInWithGoogle, SignInWithGitHub } from "@authlocker/react/client";

<SignInWithGoogle>Continue with Google</SignInWithGoogle>
<SignInWithGitHub callbackURL="/dashboard">Continue with GitHub</SignInWithGitHub>
<SignInWith provider="google">Continue with Google</SignInWith>

provider is a string your adapter resolves, not one AuthLocker resolves. Through createBetterAuthAdapter it is a Better Auth providerId, so "google" needs a genericOAuth config registered under exactly that id, with authorizationUrlParams: { provider: "google" } — AuthLocker's authorization_endpoint is /oauth/authorize and refuses a request that names no provider — and {baseURL}/api/auth/callback/google on the client's registered redirect_uri list. Through createAuthLockerAdapter it is the segment in oauth/{provider}/start, which your handler forwards.

A resolved beginSocialSignIn is never a signed-in user: the browser has left, and the answer arrives at your callback route. onError fires only when the navigation did not happen at all.

Composing with your own components

Composition is Base UI's render prop — pass an element, and the part's props and handlers are merged onto it:

<SignInWithGoogle render={<Button variant="outline" />}>
  Continue with Google
</SignInWithGoogle>

<Otp.CodeField render={<InputOTP maxLength={6} />} />

The merge rules, all of which exist because the naive version breaks something:

  • your handlers run first, ours second, and calling preventDefault() cancels ours — that is how you veto a send;
  • className concatenates and style merges, yours last;
  • disabled is OR-ed, never overridden, so disabled={false} cannot re-open a trigger the machine has closed;
  • type and disabled are dropped when the target is a non-control element such as <a>, where they are invalid and, in disabled's case, silently ignored — the state moves to aria-disabled and the trigger re-checks it in its own handler.

Otp.CodeField and Otp.TargetField accept a value-based onChange as well as a DOM event, which is what makes render={<InputOTP />} work.

Only the element form is supported, not Base UI's render={(props) => …} function form. useOtp() covers that case with more control. And do not render an <a href> through a button primitive to get a link that looks like a button: that stamps role="button" on something which follows an href. Style a plain <a> instead.

Hooks

For markup you own entirely:

const otp = useOtp({ channel: "email", template: "signin" });
// otp.state, .target, .setTarget, .code, .setCode, .verification, .error,
// .attemptsRemaining, .resendsRemaining, .secondsRemaining,
// .canStart, .canVerify, .canResend, .start, .verify, .resend, .reset

const { signIn, state, error } = useSocialSignIn({ callbackURL: "/dashboard" });

The state machine

idle → starting → awaiting_code → verifying → verified
                       ↓              ↓
                    failed         locked          (+ resend cooldown timer)
StateMeans
idleWaiting for an address.
startingThe send is in flight.
awaiting_codeA code is out. The cooldown is counting down.
verifyingThe check is in flight.
verifiedDone — and verification.to is now the real address.
failedThe flow cannot continue. reset() is the exit.
lockedFive attempts spent. Terminal; resending does not recover it.

Error handling is part of the contract, not a detail:

CodeEffect
INVALID_CODEStays in awaiting_code, clears the field, decrements attemptsRemaining.
VERIFICATION_LOCKEDlocked. Terminal.
VERIFICATION_EXPIREDidle, target kept, error kept so you can say why.
VERIFICATION_ALREADY_VERIFIED, NOT_FOUNDfailed.
RESEND_TOO_SOONRestarts the cooldown from Retry-After, or the live deadline, or 60s.
RESEND_LIMIT_REACHEDresendsRemaining → 0; the trigger stays closed.
anything elseStays put — a throttle or a dropped connection is not a dead end.

attemptsRemaining arrives as a sibling of error in AuthLocker's failure body, not inside it. Both adapters know that. If you write your own, keep it.

Service constants, exported as VERIFICATION_CONSTANTS: a ten-minute TTL, five attempts, three resends, a sixty-second resend interval, six-digit codes.

Parts

PartElementAttributes
Otp.Rootdivdata-authlocker-otp, data-state, data-channel
Otp.TargetFieldinputdata-state, data-channel, data-disabled
Otp.StartTriggerbuttondata-state, data-pending, data-disabled
Otp.CodeFieldinputdata-state, data-complete, data-disabled
Otp.VerifyTriggerbuttondata-state, data-pending, data-disabled
Otp.ResendTriggerbuttondata-state, data-cooldown, data-exhausted, data-disabled
Otp.Statusdivrole="status", aria-live="polite", data-state
Otp.Errordivrole="alert", data-code; renders nothing until something fails

Otp.Root also takes children as a function of the machine, and useOtpContext() lets you write a part we did not think of.

A note on the address

verification.to is masked (a**@example.com, +*******1234) on every response but one: a successful check returns it in full, because proving possession is what earns it back. It is fine to render. It does not belong in a log line, an analytics event, or an error message — which is why nothing this package throws ever quotes a payload.

Entry points

ImportContains
@authlocker/reactTypes, AuthLockerError, the adapters. No React — safe in a server component.
@authlocker/react/clientThe components and hooks. "use client".

MIT.

Keywords

authlocker

FAQs

Package last updated on 02 Sep 2026

Related posts