비트베이크

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-06-16T05:01:55.625Z

2026 다이소 여름 신상/인기템! 시원한 여름 꿀템 총정리

2026년 다이소 여름 신상부터 인기 쿨링템, 장마철 필수품, 홈캉스 아이템까지! 가성비 넘치는 다이소 여름 꿀템으로 시원하고 쾌적한 여름을 준비하는 완벽 가이드.

2026-06-16T05:01:31.367Z

지속 가능한 국내 워케이션: 2026년 숨은 보석 여행지

2026년 국내 워케이션 트렌드는 지속가능한 여행과 만납니다. 디지털 디톡스, 친환경 숙소, 로컬 체험을 통해 몸과 마음을 치유하고 지역 경제 활성화에 기여하는 숨은 명소 3곳을 소개합니다. 지금 바로 나만의 지속 가능한 워케이션을 계획해보세요!

2026-06-16T05:01:30.087Z

2026년 최신 의학 트렌드: AI와 정밀의료로 여는 초개인화 건강관리

2026년, AI와 정밀의료가 이끄는 초개인화 건강관리 시대가 열렸습니다. 딥러닝 기반 진단, 유전체 맞춤 치료, 웨어러블 및 디지털 치료제가 일상 속 건강을 혁신합니다. 미래 의학의 도전 과제와 현명한 건강 관리법을 알아보세요.

2026-06-16T05:01:16.613Z

2026 가을/겨울 출산준비물: 신생아 육아템 필수템 총정리

2026년 가을/겨울 출산을 앞둔 예비맘들을 위한 완벽 가이드! 최신 트렌드를 반영한 신생아 육아템 필수템부터 대형 육아용품 비교, 스마트한 케어 및 수유 용품, 쌀쌀한 날씨 대비 아기옷, 그리고 알뜰 구매 팁까지 모든 출산준비물을 총정리했습니다.

서비스

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

문의

비트베이크

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

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

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