@authlocker/better-auth

中文文档
0.0.2发布于 2026/09/02MITTypeScriptESM + CJS近 30 天下载 3348

Better Auth plugin for AuthLocker: email and SMS one-time codes verified by authlocker.dev, with the session created for you.

原始使用文档
文档目录18 节
  1. What it does, and what it deliberately does not
  2. Setup
  3. 1. lib/auth.ts
  4. 2. app/api/auth/[...all]/route.ts
  5. 3. lib/auth-client.ts
  6. 4. Run the migration
  7. Using it
  8. The errors are the API
  9. The first SMS you send will probably fail
  10. Social login: genericOAuth , no plugin of ours
  11. Register the client once per app, not once per feature
  12. Already using Better Auth's emailOTP() ?
  13. Options
  14. Rate limiting
  15. Server-side calls
  16. What verify-otp returns
  17. What is never logged
  18. Licence

@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:

EndpointClient callDoes
POST /api/auth/authlocker/send-otpauthClient.authlocker.sendOtp()Asks AuthLocker to generate and deliver a code
POST /api/auth/authlocker/verify-otpauthClient.authlocker.verifyOtp()Checks it, finds or creates the user, sets the session cookie
POST /api/auth/authlocker/resend-otpauthClient.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.codeStatusWhat it means
INVALID_CODE400Wrong code. Also carries attemptsRemaining.
VERIFICATION_LOCKED409Five attempts spent. Terminal — send a new code.
VERIFICATION_EXPIRED400Past the 10-minute TTL. Blameless; offer a resend.
VERIFICATION_ALREADY_VERIFIED409Single use. Usually a double-submitted form.
RESEND_TOO_SOON429Inside the 60-second window.
RESEND_LIMIT_REACHED429Three resends spent on this verification.
NOT_FOUND404Unknown or reaped verificationId.
SMS_REQUIRES_CLAIMED_CLIENT403See below.
SMS_COUNTRY_BLOCKED403That dialling code is not enabled for this client.
SMS_DAILY_CAP_REACHED429The client's own daily SMS cap.
USER_NOT_FOUND400disableSignUp is on and nobody owns that address. The code was correct; do not tell the person it was wrong.
AUTHLOCKER_UNREACHABLE503Network. 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 discovered authorization_endpoint is /oauth/authorize, and AuthLocker refuses a request that names no provider — so each entry needs authorizationUrlParams: { provider: "google" }. The providerId is also the exact string you pass to signIn.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/google and /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_uri is matched exactly and cannot be changed after registration without registering again.
  • Omit redirectURI and let Better Auth derive it from baseURL. 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:

PathWindowMax
/authlocker/send-otp, /authlocker/resend-otp60s5
/authlocker/verify-otp60s10

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

版本与发布记录

@authlocker/better-auth 共发布 2 个版本,首次发布于 2026/09/01;过去 12 个月发布了 2 个版本。

版本发布日期距今
0.0.2latest2026/09/022 个月前
0.0.12026/09/012 个月前

关于 @authlocker/better-auth 的常见问题

@authlocker/better-auth 是什么?

Better Auth plugin for AuthLocker: email and SMS one-time codes verified by authlocker.dev, with the session created for you.

如何安装 @authlocker/better-auth?

在项目目录运行 npm install @authlocker/better-auth,或使用 pnpm add / yarn add / bun add。它还需要宿主项目提供 peer 依赖:better-auth(>=1.7.0)。npm 7 及以上与 pnpm 8 及以上默认会自动安装必需的 peer 依赖,Yarn 需要手动安装。

@authlocker/better-auth 支持 TypeScript 吗?

支持。包内自带 TypeScript 类型声明(package.json 声明了 types 入口),安装后即可获得类型提示,无需再安装 @types 包。

@authlocker/better-auth 支持 ESM 还是 CommonJS?

同时提供 ES Module 与 CommonJS 入口,import 和 require() 都可以使用,打包工具会自动选择合适的版本。

@authlocker/better-auth 可以商用吗?

@authlocker/better-auth 使用 MIT 许可。宽松许可:允许商业使用、修改和再分发,通常只需在分发时保留原版权与许可声明。以上为概括,不构成法律意见,请以许可原文为准。

@authlocker/better-auth 的下载量有多少?

根据 npm 官方下载统计,@authlocker/better-auth 在 2026/10/07 – 2026/11/05 这 30 天内被下载 3,348 次,日均约 112 次。下载量会把 CI 构建、镜像同步和作为其他包依赖的安装一并计入,适合横向比较热度,不能直接等同于使用人数。

@authlocker/better-auth 还在维护吗?

最新版本 0.0.2 发布于 2026/09/02(2 个月前);至今共发布 2 个版本,其中过去 12 个月发布了 2 个;首次发布于 2026/09/01。发布频率只是参考信号:功能稳定的工具可能很少需要更新,建议结合源码仓库的 issue 和提交记录判断。