Phone sign-in for Auth.js with TextKit Verify

Auth.js has no phone or SMS sign-in of its own: its built-in ways to sign in are OAuth, magic links by email, credentials and passkeys, so there is no Auth.js code to deliver. You can still sign people in by phone. The Credentials provider accepts any check you write, and TextKit Verify is that check: one call texts a code, a second says whether the code the user typed is right. TextKit creates the code, sends it, expires it after 10 minutes and allows 5 tries, so you store nothing.

Before you start

  • An Auth.js app. This guide uses Next.js (next-auth); other frameworks import the Credentials provider from their own package.
  • The SDK: npm install jointextkit.
  • A TextKit test key, stored as TEXTKIT_API_KEY. Sign up and copy one from the console. Restrict it to the verify scope (scopes).
  • A function of yours, findOrCreateUserByPhone, that returns the user for a verified number. The Credentials provider does not save users to a database by default, so this is where your app does.

1. The provider

typescript
// auth.ts
import NextAuth from 'next-auth';
import Credentials from 'next-auth/providers/credentials';
import { TextKit, TextKitError } from 'jointextkit';
import { findOrCreateUserByPhone } from '@/lib/users'; // yours: look up or create the user

const textkit = new TextKit(process.env.TEXTKIT_API_KEY);

export const { handlers, auth, signIn, signOut } = NextAuth({
  providers: [
    Credentials({
      credentials: { phone: {}, code: {} },
      authorize: async (credentials) => {
        const phone = String(credentials.phone ?? '');
        const code = String(credentials.code ?? '');
        try {
          const { verified } = await textkit.verify.check({ to: phone, code });
          if (!verified) return null; // wrong code
        } catch (err) {
          // No code pending, expired, or already used.
          if (err instanceof TextKitError) return null;
          throw err;
        }
        return findOrCreateUserByPhone(phone);
      },
    }),
  ],
});

authorize gets the phone and code fields from the form, asks TextKit whether the code is right, and returns a user only when it is. Returning null fails the sign-in. A wrong code answers verified: false. An expired code, or none sent, answers 404 verification_not_found, and a second check of an approved code answers 409 verification_already_approved. The SDK throws a TextKitError for both, and the provider treats them as a failed sign-in.

2. Send and check the code

Two server actions: one texts the code, the other hands phone and code to Auth.js.

typescript
// app/sign-in/actions.ts
'use server';
import { headers } from 'next/headers';
import { AuthError } from 'next-auth';
import { TextKit, TextKitError } from 'jointextkit';
import { signIn } from '@/auth';

const textkit = new TextKit(process.env.TEXTKIT_API_KEY);

export async function sendCode(formData: FormData) {
  const ip = (await headers()).get('x-forwarded-for')?.split(',')[0]?.trim();
  try {
    await textkit.verify.send({
      to: String(formData.get('phone')),
      appName: 'Acme',
      ...(ip ? { userIp: ip } : {}),
    });
    return { sent: true };
  } catch (err) {
    if (err instanceof TextKitError) return { error: err.message }; // blocked, cooldown, bad number
    throw err;
  }
}

export async function checkCode(formData: FormData) {
  try {
    await signIn('credentials', formData); // phone and code from the form
  } catch (err) {
    if (err instanceof AuthError) return { error: 'That code is wrong or has expired.' };
    throw err; // the redirect after a successful sign-in
  }
}

Your sign-in page is two forms: a phone field posting to sendCode, then a hidden phone field and a code field posting to checkCode. In a server action a failed sign-in throws, so checkCode turns it into a message for the form.

userIp turns on Verify's limit of 20 codes an hour per IP address, which stops one person requesting codes for many numbers. Verify also refuses numbers outside the US and Canada, VoIP numbers and toll-free numbers, and allows one code a minute per number. A refused send throws a TextKitError: verification_blocked with a reason, or 429 rate_limited inside the cooldown. The text itself is a fixed TextKit template with your appName in it (24 characters at most).

3. Test in the sandbox

Test keys never text anyone from Verify. Every code is 111111, which signs in. 000000 is a wrong code and 222222 an expired one. The sandbox has no resend cooldown: send to +15005550003 to see the cooldown's 429, and to +15005550025 to see a VoIP number refused. See Verify for every case.

Go live

  1. Swap in a live key (ssms_sk_live_...), restricted to verify.
  2. Wait for live access. Verify needs no phone number of your own: codes go out from TextKit's verification numbers. Until your account is live, a live key answers 403 live_access_required. An account goes live when the carriers verify its toll-free number, usually in 3 to 7 business days.
  3. Add credits. Free accounts text codes only to numbers they have verified (credits).

A verification costs $0.025, charged only when a code is verified: wrong codes, expired codes and blocked numbers cost nothing. Verify codes are not held for review in your first 30 days of live sending.

Sources

Provider steps were checked against these pages on 2026-10-28:

More in integrations