Implementing SMS Authentication in Next.js 15 App Router — 2026 Security Guide

2026년 3월 30일10분 소요

NEXTJS15-SMS-2026

Implementing SMS Authentication in Next.js 15 App Router — 2026 Security Guide

> You just want to add phone verification to your side project, but every SMS provider demands business registration documents, a pre-registered sender ID, and a week-long review process. Sound familiar? In this guide, we'll implement production-ready SMS OTP authentication in a Next.js 15 App Router application — with proper rate limiting, input validation, and 2026 security best practices.

What You'll Learn

  • How to use Next.js 15 App Router's Route Handlers and Server Actions for SMS auth
  • Complete send-and-verify OTP flow implementation
  • Rate limiting with Upstash to prevent SMS pumping attacks
  • 2026 SMS authentication security best practices and threat mitigation

Prerequisites

  • Node.js 18+ and npm/pnpm
  • Basic familiarity with Next.js and TypeScript
  • An EasyAuth API key (free tier: 10 messages)
  • An Upstash Redis instance (free tier available)

Step 1: Project Setup

Create the Next.js 15 App

npx create-next-app@latest my-sms-auth --app --typescript
cd my-sms-auth
npm install @upstash/ratelimit @upstash/redis

Next.js 15 uses the App Router by default. We'll create Route Handlers inside the app/ directory using route.ts files that export HTTP method handlers.

Environment Variables

# .env.local
EASYAUTH_API_KEY=your_api_key_here
UPSTASH_REDIS_REST_URL=your_upstash_url
UPSTASH_REDIS_REST_TOKEN=your_upstash_token

Step 2: Build the Send OTP Endpoint

Create app/api/auth/send/route.ts:

// app/api/auth/send/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

// Rate limit: 1 request per 60 seconds per IP
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(1, '60 s'),
  prefix: 'sms-send',
});

export async function POST(request: NextRequest) {
  try {
    // 1. Rate limiting check
    const ip = request.headers.get('x-forwarded-for') ?? 'anonymous';
    const { success } = await ratelimit.limit(ip);

    if (!success) {
      return NextResponse.json(
        { error: 'Too many requests. Please try again in 60 seconds.' },
        { status: 429 }
      );
    }

    // 2. Validate phone number
    const { phoneNumber } = await request.json();
    const phoneRegex = /^\+?[1-9]\d{6,14}$/; // E.164-like format

    if (!phoneRegex.test(phoneNumber)) {
      return NextResponse.json(
        { error: 'Invalid phone number format.' },
        { status: 400 }
      );
    }

    // 3. Send OTP via SMS API
    const response = await fetch('https://api.easyauth.io/send', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${process.env.EASYAUTH_API_KEY}`,
      },
      body: JSON.stringify({ phoneNumber }),
    });

    const data = await response.json();

    if (!response.ok) {
      console.error('SMS send failed:', data);
      return NextResponse.json(
        { error: 'Failed to send verification code.' },
        { status: 500 }
      );
    }

    return NextResponse.json({
      success: true,
      message: 'Verification code sent.',
      requestId: data.requestId,
    });
  } catch (error) {
    console.error('Send OTP error:', error);
    return NextResponse.json(
      { error: 'Internal server error.' },
      { status: 500 }
    );
  }
}

Key Design Decisions

  • Rate limiting before validation: We check the rate limit first to reject abusive requests before any processing.
  • Sliding window algorithm: More forgiving than fixed window — users won't hit edge cases at window boundaries.
  • IP-based limiting: For the send endpoint, we limit per IP since the user hasn't authenticated yet.

Step 3: Build the Verify OTP Endpoint

// app/api/auth/verify/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

// Verify: 5 attempts per 60 seconds per IP (brute-force protection)
const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(5, '60 s'),
  prefix: 'sms-verify',
});

export async function POST(request: NextRequest) {
  try {
    const ip = request.headers.get('x-forwarded-for') ?? 'anonymous';
    const { success } = await ratelimit.limit(ip);

    if (!success) {
      return NextResponse.json(
        { error: 'Too many verification attempts. Please wait and try again.' },
        { status: 429 }
      );
    }

    const { requestId, code } = await request.json();

    if (!requestId || !code || code.length !== 6) {
      return NextResponse.json(
        { error: 'Invalid request. Please provide requestId and 6-digit code.' },
        { status: 400 }
      );
    }

    const response = await fetch('https://api.easyauth.io/verify', {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
        'Authorization': `Bearer ${process.env.EASYAUTH_API_KEY}`,
      },
      body: JSON.stringify({ requestId, code }),
    });

    const data = await response.json();

    if (!response.ok || !data.verified) {
      return NextResponse.json(
        { error: 'Invalid verification code.' },
        { status: 400 }
      );
    }

    // Verification successful — issue session/token here
    return NextResponse.json({
      success: true,
      message: 'Phone number verified successfully.',
    });
  } catch (error) {
    console.error('Verify OTP error:', error);
    return NextResponse.json(
      { error: 'Internal server error.' },
      { status: 500 }
    );
  }
}

Step 4: Client Component

// app/components/SmsVerification.tsx
'use client';

import { useState, useTransition } from 'react';

type Step = 'phone' | 'verify' | 'done';

export default function SmsVerification() {
  const [step, setStep] = useState('phone');
  const [phoneNumber, setPhoneNumber] = useState('');
  const [code, setCode] = useState('');
  const [requestId, setRequestId] = useState('');
  const [error, setError] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleSendOTP = () => {
    startTransition(async () => {
      setError('');
      const res = await fetch('/api/auth/send', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ phoneNumber }),
      });
      const data = await res.json();

      if (!res.ok) {
        setError(data.error);
        return;
      }
      setRequestId(data.requestId);
      setStep('verify');
    });
  };

  const handleVerify = () => {
    startTransition(async () => {
      setError('');
      const res = await fetch('/api/auth/verify', {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify({ requestId, code }),
      });
      const data = await res.json();

      if (!res.ok) {
        setError(data.error);
        return;
      }
      setStep('done');
    });
  };

  if (step === 'done') {
    return (
      <div>
        Phone number verified successfully!
      </div>
    );
  }

  return (
    <div>
      <h2>Phone Verification</h2>

      {step === 'phone' &amp;&amp; (
        &lt;&gt;
           setPhoneNumber(e.target.value)}
            className="w-full border rounded px-3 py-2"
          /&gt;
          
            {isPending ? 'Sending...' : 'Send Verification Code'}
          
        &lt;/&gt;
      )}

      {step === 'verify' &amp;&amp; (
        &lt;&gt;
          <p>Enter the 6-digit code sent to your phone.</p>
           setCode(e.target.value)}
            className="w-full border rounded px-3 py-2 text-center text-2xl tracking-widest"
          /&gt;
          
            {isPending ? 'Verifying...' : 'Verify'}
          
        &lt;/&gt;
      )}

      {error &amp;&amp; <p>{error}</p>}
    </div>
  );
}

Step 5: Server Actions Alternative

Next.js 15 Server Actions offer a more streamlined approach. Server Actions automatically compare the Origin header against the Host header, providing built-in CSRF protection — a meaningful security advantage over plain Route Handlers.

// app/actions/sms.ts
'use server';

import { headers } from 'next/headers';
import { Ratelimit } from '@upstash/ratelimit';
import { Redis } from '@upstash/redis';

const ratelimit = new Ratelimit({
  redis: Redis.fromEnv(),
  limiter: Ratelimit.slidingWindow(1, '60 s'),
  prefix: 'sms-action',
});

export async function sendOTP(phoneNumber: string) {
  const headersList = await headers();
  const ip = headersList.get('x-forwarded-for') ?? 'anonymous';
  const { success } = await ratelimit.limit(ip);

  if (!success) {
    return { error: 'Too many requests. Please wait and try again.' };
  }

  const res = await fetch('https://api.easyauth.io/send', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.EASYAUTH_API_KEY}`,
    },
    body: JSON.stringify({ phoneNumber }),
  });

  const data = await res.json();
  if (!res.ok) return { error: 'Failed to send verification code.' };

  return { success: true, requestId: data.requestId };
}

export async function verifyOTP(requestId: string, code: string) {
  const headersList = await headers();
  const ip = headersList.get('x-forwarded-for') ?? 'anonymous';
  const { success } = await ratelimit.limit(`verify-${ip}`);

  if (!success) {
    return { error: 'Too many attempts. Please wait.' };
  }

  const res = await fetch('https://api.easyauth.io/verify', {
    method: 'POST',
    headers: {
      'Content-Type': 'application/json',
      'Authorization': `Bearer ${process.env.EASYAUTH_API_KEY}`,
    },
    body: JSON.stringify({ requestId, code }),
  });

  const data = await res.json();
  if (!res.ok || !data.verified) return { error: 'Invalid code.' };

  return { success: true };
}

> Note on Server Actions vs Route Handlers: Use Server Actions for form-driven flows within your app. Use Route Handlers when you need a public API endpoint (e.g., for webhooks or third-party integrations).


2026 SMS Authentication Security Checklist

Security MeasureDescriptionPriority
Rate LimitingLimit send (1/min) and verify (5/min) per IPCritical
OTP ExpirationSet 3-5 minute TTL on verification codesCritical
Input ValidationValidate phone format before sendingCritical
HTTPS OnlyEncrypt all API communicationCritical
Attempt LimitingLock after 5 failed verificationsHigh
Logging & MonitoringDetect anomalous patterns (spike in sends)High
SameSite CookiesSet SameSite=Lax or Strict on session cookiesHigh
Middleware PatchingEnsure Next.js >= 15.2.3 (CVE-2025-29927 fix)Critical

SIM Swapping: The Persistent Threat

In 2026, SIM swapping remains the most significant vulnerability in SMS-based authentication. Attackers social-engineer mobile carriers into transferring a victim's number to a new SIM. For this reason, SMS OTP is classified as medium-assurance authentication by security standards bodies.

For most use cases — sign-ups, identity verification, account recovery — SMS OTP remains the most practical and cost-effective choice. For high-risk actions like financial transactions, layer SMS with TOTP or passkeys.

Next.js 15 Security Note

A critical vulnerability (CVE-2025-29927, CVSS 9.1) was disclosed in March 2025 that allowed attackers to bypass all middleware by sending an x-middleware-subrequest header. This was fixed in Next.js 15.2.3. Always ensure you're running a patched version.


Project Structure Overview

app/
├── api/
│   └── auth/
│       ├── send/
│       │   └── route.ts      # OTP send endpoint
│       └── verify/
│           └── route.ts      # OTP verify endpoint
├── actions/
│   └── sms.ts                # Server Actions alternative
├── components/
│   └── SmsVerification.tsx   # Client component
└── page.tsx                  # Main page

Tips & Best Practices

  1. Never expose your API key client-side. All SMS API calls must happen in Route Handlers or Server Actions — never in client components.

  2. Use useTransition for pending states. This provides a better UX than manual loading state management and integrates with React's concurrent features.

  3. Validate on both sides. Check phone number format on the client for UX, and re-validate on the server for security.

  4. Log everything, expose nothing. Log failed attempts and anomalous patterns server-side, but only return generic error messages to clients.

  5. Consider phone number hashing. If you store phone numbers, hash them. You likely don't need to display the full number after verification.


Conclusion

Implementing SMS authentication in Next.js 15's App Router is straightforward — the real friction has always been on the provider side: paperwork, sender ID registration, lengthy approval processes.

EasyAuth eliminates that friction entirely. No business documents required, no sender ID pre-registration, and you get an API key instantly after sign-up. The entire integration is just two endpoints: POST /send and POST /verify. With 10 free messages on sign-up and pricing at 15-25 KRW per message (roughly half the industry average), it's ideal for side projects, MVPs, and startups that need to move fast.

Copy the code from this guide, grab your free API key, and ship SMS auth today.


This guide was written as of March 2026 and reflects Next.js 15.x with the latest security recommendations.

Sources

SMS 인증을 쉽게 시작하세요

서류 없이 가입 즉시 API Key를 발급받고 바로 시작할 수 있습니다.
건당 25원, 가입 시 10건 무료!