Components
Build the sign-in screen you actually want.
Turn the ways in on and off and put them in the order you want. Everything here is one ordered capabilities array, so the rail and the prop are the same thing said two ways — and the code underneath, client and server, is generated from it.
The preview is not a mock. It is the real card against a real client, so it sends real email and real SMS and really redirects you to Google — use your own address. A bot check guards every send.
Blocks · drag to reorder
Redirects to the provider through AuthLocker.
Six digits, delivered by SES.
Six digits by SMS.
Live preview
3 components
import { SignIn } from "@authlocker/react/client"; import { Card, CardContent, CardDescription, CardHeader, CardTitle, } from "@/components/ui/card"; import { FieldSeparator } from "@/components/ui/field"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { EmailOtpForm } from "@/components/email-otp-form"; import { PhoneOtpForm } from "@/components/phone-otp-form"; import { SocialButtons } from "@/components/social-buttons"; export default function SignInPage() { // The card supplies the heading and the padding, so each flow swaps its own // <Card> shell for a plain <div> and zeroes the spacing variable. const embedded = <div className="flex flex-col gap-6 [--card-spacing:0px]" />; const otpProps = { title: null, description: null, render: embedded }; // Once a code is out, everything offering another way in goes away: // choosing one now would abandon a half-finished verification. return ( <SignIn.Root render={<Card className="w-full max-w-sm" />}> <CardHeader> <CardTitle>Sign in</CardTitle> <CardDescription> Continue with a provider, or we will send you a one-time code. </CardDescription> </CardHeader> <CardContent className="gap-6"> <SignIn.Alternatives> <SocialButtons /> {/* the card's own separator, kept off the page background */} <FieldSeparator className="[&_[data-slot=field-separator-content]]:bg-card"> or </FieldSeparator> </SignIn.Alternatives> <Tabs defaultValue="email" className="gap-4"> {/* * Only the strip is hidden, never the panel: the panel holds the flow * in progress, and unmounting it would throw the verification away * along with the choice. */} <SignIn.Alternatives> <TabsList className="w-full"> <TabsTrigger value="email">Email</TabsTrigger> <TabsTrigger value="phone">Phone</TabsTrigger> </TabsList> </SignIn.Alternatives> <TabsContent value="email"><EmailOtpForm {...otpProps} /></TabsContent> <TabsContent value="phone"><PhoneOtpForm {...otpProps} /></TabsContent> </Tabs> </CardContent> </SignIn.Root> ); }
The server half
The verify API takes your client secret and answers no CORS headers, so a browser can never call it. Something on your origin has to hold the credential — this is generated from the same arrangement as everything above.
// app/api/auth/verify-user-identity/[...path]/route.ts import { createAuthLockerHandler } from "@authlocker/next"; import { cookies } from "next/headers"; export const { GET, POST } = createAuthLockerHandler({ // A security boundary, not decoration: a request for a capability that is // not listed is refused before your credential is ever used. capabilities: [ "socials.google", "socials.github", "otp.email", "otp.phone", ], // Runs once a code checks out or a provider returns — one place your app // decides what a verified person means to it. async onUserVerified(request, user) { // user: { method, provider, sub, email, emailVerified, // phoneNumber, phoneNumberVerified, name, picture } // 1. Find or create your own row. // const account = await db.user.upsert(...) // 2. Establish the session, on this very response. Next 15: cookies() is // async, and Next 16 drops the synchronous form entirely. // // const jar = await cookies(); // jar.set("session", await encrypt(account.id), { // httpOnly: true, // sameSite: "lax", // never "strict" — see below // secure: process.env.NODE_ENV === "production", // path: "/", // }); // // Signing a JWT instead works the same way: sign it here, set it there. // 3. Refuse anyone you do not want. This is the only way to say no: // // throw new AuthLockerHandlerError({ // code: "VALIDATION_ERROR", // message: "That address is not on the invite list.", // }); // 4. Optionally hand the browser something a cookie cannot carry — a // bearer for a separate API. It arrives beside the verification. // Never a refresh token: this lands in JavaScript. // // return { session: { token } }; return { redirectTo: "/dashboard" }; }, }); // The two things that break this flow if got wrong, both handled for you: the // PKCE verifier, state and nonce ride in httpOnly cookies with sameSite "lax" // — "strict" withholds them on the top-level cross-site return and the // exchange finds nothing — and the id_token is verified against the published // JWKS with an explicit ES256 allowlist, not decoded.
// app/layout.tsx import { AuthLockerNextProvider } from "@authlocker/next/client"; // No path, no adapter, no configuration: the provider and the handler read the // same constant, so the two halves cannot drift. export default function RootLayout({ children, }: { children: React.ReactNode; }) { return ( <html lang="en"> <body> <AuthLockerNextProvider>{children}</AuthLockerNextProvider> </body> </html> ); }
Credentials
One pair drives all of it.
Registration is unauthenticated and returns credentials immediately. Everything above reads the same three environment variables, so the CLI writes them to your .env on the first install and adds nothing on the rest.
curl -X POST 'https://authlocker.dev/oauth/register?format=env' \
-H 'Content-Type: application/json' \
-d '{ "client_name": "Acme" }' >> .envNo callback URL, because you do not need to know it yet: the first one your app actually uses is pinned to the client, per provider. That pair sends email and SMS straight away. An unclaimed client draws SMS from a shared daily ceiling, which is enough to build against and not enough to be worth abusing; claiming it with POST /api/v1/clients/{client_id}/claim or provisioning one with npx botparty authlocker provision replaces that ceiling with a budget of your own.