
Security News
Lovable’s OJ Rewrites Vite’s Dev Server in Rust as AI Lowers the Cost of Forking Open Source
Lovable’s OJ rewrites Vite’s dev server in Rust, reducing memory use and preview times as AI lowers the cost of open source reimplementation.
@authlocker/react
Advanced tools
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.
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
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.
createAuthLockerAdapteris written but its server is not.@authlocker/nextand 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 — proxyverify/start,verify/checkandverify/resendwith the HTTP Basic header, and redirectoauth/{provider}/startto the authorize endpoint. Forward AuthLocker's failure body unchanged:attemptsRemainingrides besideerror, not inside it, and re-wrapping it costs the UI its attempt counter.
"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 }
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.
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.
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:
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.
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" });
idle → starting → awaiting_code → verifying → verified
↓ ↓
failed locked (+ resend cooldown timer)
| State | Means |
|---|---|
idle | Waiting for an address. |
starting | The send is in flight. |
awaiting_code | A code is out. The cooldown is counting down. |
verifying | The check is in flight. |
verified | Done — and verification.to is now the real address. |
failed | The flow cannot continue. reset() is the exit. |
locked | Five attempts spent. Terminal; resending does not recover it. |
Error handling is part of the contract, not a detail:
| Code | Effect |
|---|---|
INVALID_CODE | Stays in awaiting_code, clears the field, decrements attemptsRemaining. |
VERIFICATION_LOCKED | → locked. Terminal. |
VERIFICATION_EXPIRED | → idle, target kept, error kept so you can say why. |
VERIFICATION_ALREADY_VERIFIED, NOT_FOUND | → failed. |
RESEND_TOO_SOON | Restarts the cooldown from Retry-After, or the live deadline, or 60s. |
RESEND_LIMIT_REACHED | resendsRemaining → 0; the trigger stays closed. |
| anything else | Stays 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.
| Part | Element | Attributes |
|---|---|---|
Otp.Root | div | data-authlocker-otp, data-state, data-channel |
Otp.TargetField | input | data-state, data-channel, data-disabled |
Otp.StartTrigger | button | data-state, data-pending, data-disabled |
Otp.CodeField | input | data-state, data-complete, data-disabled |
Otp.VerifyTrigger | button | data-state, data-pending, data-disabled |
Otp.ResendTrigger | button | data-state, data-cooldown, data-exhausted, data-disabled |
Otp.Status | div | role="status", aria-live="polite", data-state |
Otp.Error | div | role="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.
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.
| Import | Contains |
|---|---|
@authlocker/react | Types, AuthLockerError, the adapters. No React — safe in a server component. |
@authlocker/react/client | The components and hooks. "use client". |
MIT.
FAQs
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.
We found that @authlocker/react demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 1 open source maintainer collaborating on the project.

Security News
Lovable’s OJ rewrites Vite’s dev server in Rust, reducing memory use and preview times as AI lowers the cost of open source reimplementation.

Security News
It has been one year since Shai-Hulud made its first appearance on npm.

Research
/Security News
Operators behind PolinRider used a compromised GitHub account to plant malware in four development versions of a Packagist package with 700,000+ downloads.