비트베이크

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-08-06T06:01:33.120Z

2026 GTX 개통 임박! A/B/C 노선 수혜지역 투자 가이드

2026년 GTX A/B/C 노선 개통이 임박하며 수도권 부동산 시장이 들썩이고 있습니다. GTX 노선별 개통 현황과 함께, 주요 수혜지역을 심층 분석하고 실거주 및 투자를 위한 현명한 전략과 유의점을 제시하여 성공적인 아파트 투자를 돕는 가이드입니다.

2026-08-05T06:01:33.825Z

2026 하반기 재건축 투자: 규제 완화 속 핵심 전략

2026년 하반기, 규제 완화 기대감 속 재건축 투자의 핵심 전략을 알아봅니다. 정부 정책 변화 분석, 유망 지역 선정 기준, 주의할 점, 그리고 성공적인 투자를 위한 전문가들의 조언까지, 2026 부동산 시장에서 기회를 잡을 방법을 제시합니다.

2026-08-04T06:01:37.246Z

2026 하반기 청약, 대출 금리 변화 활용 내집마련 필승 전략

2026년 하반기 청약 시장은 변화하는 대출 금리와 정책, 지역별 수급 상황에 따라 기회와 도전이 공존합니다. 이 글에서는 부동산 시장 동향과 주택담보대출 전략, 인기 청약 단지 분석, 청약 가점 및 특별공급 활용 팁 등 내 집 마련을 위한 필승 전략을 제시합니다. 철저한 준비와 현명한 판단으로 2026년 내 집 마련의 꿈을 이루세요.

2026-08-04T01:01:36.795Z

2026년 청약 성공 전략: 무주택자 내집마련 필승 가이드

2026년 무주택자의 내집마련 꿈을 위한 필승 청약 전략 가이드입니다. 청약 가점부터 특별공급 활용법, 현명한 대출 전략, 유망 단지 분석, 그리고 제도 변화까지 2026년 청약 성공을 위한 모든 정보를 담았습니다.

서비스

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

문의

비트베이크

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

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

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