# Phone sign-in for Auth.js with TextKit Verify

Source: https://jointextkit.com/integrations/authjs
Index: https://jointextkit.com/llms.txt

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](/signup) and copy one from the console. Restrict it to the `verify` scope ([scopes](/docs/authentication)).
- 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](/docs/verify#sandbox) 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](/docs/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:

- [Auth.js: Authentication methods](https://authjs.dev/getting-started/authentication)
- [Auth.js: Credentials provider](https://authjs.dev/getting-started/authentication/credentials)
