@authlocker/better-auth
Better Auth plugin for AuthLocker. Email and SMS one-time codes, generated and checked by AuthLocker, with the Better Auth session created for you.
pnpm add @authlocker/better-auth better-auth
Requires better-auth 1.7.0 or newer. A great deal of Better Auth material
on the internet is written against 1.6 or earlier and several of the details
below changed in 1.7 — they are called out where they matter.
What it does, and what it deliberately does not
Three endpoints, mounted inside your existing Better Auth handler:
| Endpoint | Client call | Does |
|---|---|---|
POST /api/auth/authlocker/send-otp | authClient.authlocker.sendOtp() | Asks AuthLocker to generate and deliver a code |
POST /api/auth/authlocker/verify-otp | authClient.authlocker.verifyOtp() | Checks it, finds or creates the user, sets the session cookie |
POST /api/auth/authlocker/resend-otp | authClient.authlocker.resendOtp() | Sends the same verification again |
AuthLocker generates and checks every code. This plugin never mints one,
never stores one, and never counts an attempt. Better Auth's verification
table is not used at all — replay protection, the five-attempt lockout, the
ten-minute expiry, the three-resend budget and the sixty-second resend interval
all live in AuthLocker, keyed by verificationId.
That is surprising if you open the source expecting the usual OTP bookkeeping, so it is worth stating plainly: there is none here. It is also the point. Full delegation is what keeps Twilio Verify's fraud scoring, the per-target throttles and the SMS spend caps in play. A plugin that only delivered messages would have thrown all of that away.
The one thing that does not live in AuthLocker is a limit on how fast your own app will call it, so the plugin declares its own rate limits as defence in depth: 5 sends per minute and 10 verifications per minute, per client.
Setup
1. lib/auth.ts
import { betterAuth } from "better-auth";
import { nextCookies } from "better-auth/next-js";
import { authlocker } from "@authlocker/better-auth";
export const auth = betterAuth({
database: /* your adapter */,
plugins: [
authlocker({
clientId: process.env.AUTHLOCKER_CLIENT_ID!,
clientSecret: process.env.AUTHLOCKER_CLIENT_SECRET!,
}),
// `nextCookies()` must be LAST. It works by hooking every response after
// the other plugins have written their cookies; anything registered after
// it sets cookies that never reach the browser.
nextCookies(),
],
});
The client secret stays on the server, always. POST /api/v1/verify/*
authenticates with HTTP Basic and AuthLocker sends no CORS headers at all, so a
browser could not call it even if you were willing to ship the secret to one.
That constraint is the reason this plugin exists.
Get credentials from POST /oauth/register, or npx botparty authlocker provision.
2. app/api/auth/[...all]/route.ts
import { toNextJsHandler } from "better-auth/next-js";
import { auth } from "@/lib/auth";
export const { GET, POST } = toNextJsHandler(auth.handler);
3. lib/auth-client.ts
import { createAuthClient } from "better-auth/react";
import { authlockerClient } from "@authlocker/better-auth/client";
export const authClient = createAuthClient({
plugins: [authlockerClient()],
});
Method names are derived from the endpoint paths, kebab-case to camel-case, so
/authlocker/send-otp becomes authClient.authlocker.sendOtp(). You do not
declare them.
4. Run the migration
The plugin adds phoneNumber and phoneNumberVerified to the user table —
the SMS channel needs somewhere to put a verified number and Better Auth's user
model has no column for one. The names match Better Auth's built-in
phoneNumber() plugin on purpose, so an app running both ends up with one
column rather than two.
npx @better-auth/cli migrate
Using it
"use client";
import { useState } from "react";
import { getAttemptsRemaining } from "@authlocker/better-auth/client";
import { authClient } from "@/lib/auth-client";
export function SignIn() {
const [verificationId, setVerificationId] = useState<string | null>(null);
const [attemptsLeft, setAttemptsLeft] = useState(5);
const [message, setMessage] = useState("");
async function send(to: string) {
const { data, error } = await authClient.authlocker.sendOtp({
channel: "email",
to,
});
if (error) return setMessage(error.message ?? "Could not send a code");
setVerificationId(data.verificationId);
// `data.to` is masked — "a**@example.com". Safe to show back.
setMessage(`Code sent to ${data.to}`);
}
async function verify(code: string) {
const { error } = await authClient.authlocker.verifyOtp({
verificationId: verificationId!,
code,
});
if (!error) return; // Session cookie is set. You are signed in.
switch (error.code) {
case "INVALID_CODE": {
const left = getAttemptsRemaining(error) ?? 0;
setAttemptsLeft(left);
return setMessage(`Wrong code — ${left} left`);
}
case "VERIFICATION_LOCKED":
return setMessage("Too many tries. Start again.");
case "VERIFICATION_EXPIRED":
return setMessage("That code expired. Send a new one.");
default:
setMessage(error.message ?? "Something went wrong");
}
}
}
For SMS, pass channel: "sms" and a phone number in E.164 (+15551234567).
The errors are the API
The plugin passes AuthLocker's error codes through rather than flattening them, because a sign-in form has genuinely different things to say about each one. Collapsing them all to a bare 401 — which is what a hand-rolled proxy endpoint usually does — makes a correct UI impossible to build.
error.code | Status | What it means |
|---|---|---|
INVALID_CODE | 400 | Wrong code. Also carries attemptsRemaining. |
VERIFICATION_LOCKED | 409 | Five attempts spent. Terminal — send a new code. |
VERIFICATION_EXPIRED | 400 | Past the 10-minute TTL. Blameless; offer a resend. |
VERIFICATION_ALREADY_VERIFIED | 409 | Single use. Usually a double-submitted form. |
RESEND_TOO_SOON | 429 | Inside the 60-second window. |
RESEND_LIMIT_REACHED | 429 | Three resends spent on this verification. |
NOT_FOUND | 404 | Unknown or reaped verificationId. |
SMS_REQUIRES_CLAIMED_CLIENT | 403 | See below. |
SMS_COUNTRY_BLOCKED | 403 | That dialling code is not enabled for this client. |
SMS_DAILY_CAP_REACHED | 429 | The client's own daily SMS cap. |
USER_NOT_FOUND | 400 | disableSignUp is on and nobody owns that address. The code was correct; do not tell the person it was wrong. |
AUTHLOCKER_UNREACHABLE | 503 | Network. Deliberately outside AuthLocker's vocabulary so a DNS failure is never mistaken for a wrong code. |
attemptsRemaining arrives as a sibling of error on the wire, not inside it.
The plugin lifts it onto the error body, and getAttemptsRemaining(error) from
@authlocker/better-auth/client reads it back — Better Auth's client error type
is fixed at { code, message, status, statusText }, so the extra field reaches
the browser but not the type, and that helper is the alternative to a cast.
The first SMS you send will probably fail
SMS_REQUIRES_CLAIMED_CLIENT is the single most likely first-run failure.
AuthLocker only sends SMS for clients attributed to a BotParty namespace,
because every message costs real money. Email and OAuth work on an unclaimed
client, so nothing warns you until the first channel: "sms".
Claim the client with its registration_access_token, or provision one with
npx botparty authlocker provision, and set a verify.dailySmsCap while you
are there.
The plugin also logs configuration-class failures like this one to
ctx.context.logger on the server, not only to the caller — they surface for
the first time inside a browser fetch, where a half-built form is very likely
to swallow the body and show a generic red toast.
Social login: genericOAuth, no plugin of ours
AuthLocker is a standards-compliant OIDC provider, so Better Auth's built-in
genericOAuth handles Google and GitHub through it with discovery. Nothing in
this package is involved.
import { betterAuth } from "better-auth";
import { genericOAuth } from "better-auth/plugins";
import { nextCookies } from "better-auth/next-js";
import { authlocker } from "@authlocker/better-auth";
export const auth = betterAuth({
database: /* your adapter */,
plugins: [
authlocker({
clientId: process.env.AUTHLOCKER_CLIENT_ID!,
clientSecret: process.env.AUTHLOCKER_CLIENT_SECRET!,
}),
genericOAuth({
// One entry per upstream provider, sharing a single client_id.
config: (["google", "github"] as const).map((provider) => ({
providerId: provider,
clientId: process.env.AUTHLOCKER_CLIENT_ID!,
clientSecret: process.env.AUTHLOCKER_CLIENT_SECRET!,
discoveryUrl:
"https://authlocker.dev/.well-known/openid-configuration",
// The discovered authorization_endpoint is /oauth/authorize, which
// answers 400 unless the request names a provider.
authorizationUrlParams: { provider },
scopes: ["openid", "email", "profile"],
// Defaults to true in 1.7. Written out because AuthLocker *requires*
// PKCE — leaving it implicit hides a hard requirement.
pkce: true,
})),
}),
nextCookies(),
],
});
await authClient.signIn.social({ provider: "google" });
Six things that are easy to get wrong, all of which produce confusing failures rather than clear ones:
- One config per upstream provider, not one called
"authlocker". The discoveredauthorization_endpointis/oauth/authorize, and AuthLocker refuses a request that names no provider — so each entry needsauthorizationUrlParams: { provider: "google" }. TheproviderIdis also the exact string you pass tosignIn.social()and that<SocialButtons>from the registry sends, so name them"google"and"github". - The callback is
{baseURL}/api/auth/callback/{providerId}— one per entry, so/api/auth/callback/googleand/api/auth/callback/github. Not/api/auth/oauth2/callback/..., which is pre-1.7 and is what most blog posts still show. Register the exact strings with AuthLocker:redirect_uriis matched exactly and cannot be changed after registration without registering again. - Omit
redirectURIand let Better Auth derive it frombaseURL. The upstream docs contradict themselves on this; deriving it is what actually matches the route above. authClient.signIn.social({ provider }).signIn.oauth2()no longer exists.- There is no
genericOAuthClient()any more. Nothing to add on the client for social login. nextCookies()last, as above.
Register the client once per app, not once per feature
AuthLocker issues pairwise subject identifiers, derived from
client_id :: provider :: upstream user id. Register the client twice and the
same Google account arrives under two different subs, which Better Auth
stores as two accounts without complaint. One registration per app — the two
genericOAuth entries above are fine precisely because they share a
client_id; only the upstream provider differs, and a Google identity and a
GitHub identity were always two identities.
Already using Better Auth's emailOTP()?
You cannot make emailOTP() delegate to AuthLocker. It has no verifyOTP
hook at all — it generates the code, stores it in the verification table,
counts the attempts and checks it itself, leaving your callback to do delivery
only. Wiring AuthLocker into sendVerificationOTP would demote it to a mail
relay: the code in the message would be Better Auth's, and AuthLocker's fraud
scoring, attempt lockout and per-target throttles would never run.
The built-in phoneNumber() plugin does have a verifyOTP hook and could
delegate properly for SMS. That is a real alternative if you are already
invested in it — but it covers one channel, so you would end up with two
different client APIs for the same flow, and it still leaves email undelegated.
This plugin covers both channels with one shape.
If you keep phoneNumber() alongside this plugin, the shared phoneNumber
column is intentional and the two definitions merge.
Options
authlocker({
clientId: string,
clientSecret: string,
// Base URL of the AuthLocker deployment. Default "https://authlocker.dev".
issuer?: string,
// Refuse to provision a user for an address nobody owns yet, so a correct
// code signs in an existing account or fails with USER_NOT_FOUND.
// Off by default: passwordless OTP sign-in is normally also sign-up.
disableSignUp?: boolean,
// User column holding a verified E.164 number. Default "phoneNumber".
phoneNumberField?: string,
// Placeholder address for a user who signed up by SMS — Better Auth's user
// model requires a unique email and an SMS sign-up has none. The default
// builds one under `.invalid`, which RFC 2606 reserves so it can never
// resolve. It does copy the number into a second column; override this if
// that matters to you.
getTempEmail?: (phoneNumber: string) => string,
// Display name for a newly created user. Empty by default — prompt for a
// real one afterwards rather than guessing from an address.
getName?: (to: string, channel: "email" | "sms") => string,
})
Rate limiting
The plugin declares:
| Path | Window | Max |
|---|---|---|
/authlocker/send-otp, /authlocker/resend-otp | 60s | 5 |
/authlocker/verify-otp | 60s | 10 |
Two things to know. Better Auth applies rate limits only in production by
default (rateLimit.enabled), so these are inert in next dev. And an
app-level rateLimit.customRules entry for one of these paths overrides what
the plugin sets, which is the supported way to tighten or loosen them:
betterAuth({
rateLimit: {
customRules: {
"/authlocker/send-otp": { window: 3600, max: 10 },
},
},
});
Server-side calls
Every endpoint is on auth.api as well, for server actions and route handlers:
const verification = await auth.api.sendOtp({
body: { channel: "email", to: "[email protected]" },
});
What verify-otp returns
{ verification, token, user }. token and user are what every other Better
Auth sign-in returns; verification is there because @authlocker/react types
AuthLockerAdapter.checkVerification as resolving with a Verification and
drives its OTP machine off it. Without it a correct code would fail that parse
and surface as an error to someone who had already been given a session cookie.
What is never logged
The address and the code, ever. verify/check is the one AuthLocker response
that returns the destination unmasked — the code just proved the recipient
received it — and this plugin treats that value as find-or-create input: never
logged, and written nowhere but the user row it belongs to. It does travel back
on verification.to, to the one browser that typed it a moment earlier and
alongside a user already carrying the same address; that is what lets
onVerified show the confirmation its own docs promise. Everywhere else to is
already masked (a**@example.com, +*******6789).
Licence
MIT