비트베이크

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

2026-03-30T01:04:06.784Z

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 Measure | Description | Priority | |---|---|---| | Rate Limiting | Limit send (1/min) and verify (5/min) per IP | Critical | | OTP Expiration | Set 3-5 minute TTL on verification codes | Critical | | Input Validation | Validate phone format before sending | Critical | | HTTPS Only | Encrypt all API communication | Critical | | Attempt Limiting | Lock after 5 failed verifications | High | | Logging & Monitoring | Detect anomalous patterns (spike in sends) | High | | SameSite Cookies | Set SameSite=Lax or Strict on session cookies | High | | Middleware Patching | Ensure 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

비트베이크에서 광고를 시작해보세요

광고 문의하기

다른 글 보기

2026-04-08T11:02:47.515Z

2026 Professionals Solo Party & Wine Mixer Complete Guide: Real Reviews and Success Tips for Korean Singles

2026-04-08T11:02:47.487Z

2026년 직장인 솔로파티 & 와인모임 소개팅 완벽 가이드 - 실제 후기와 성공 팁

2026-04-08T10:03:28.247Z

Complete Google NotebookLM Guide 2026: Master the New Studio Features, Video Overviews, and Gemini Canvas Integration

2026-04-08T10:03:28.231Z

2026년 구글 NotebookLM 완벽 가이드: 새로운 스튜디오 기능, 비디오 개요 및 제미나이 캔버스 통합 실전 활용법

서비스

피드자주 묻는 질문고객센터

문의

비트베이크

레임스튜디오 | 사업자 등록번호 : 542-40-01042

경기도 남양주시 와부읍 수례로 116번길 16, 4층 402-제이270호

트위터인스타그램네이버 블로그