Phone OTP login over KakaoTalk in Next.js (App Router)

The K-OTP team
Oct 25, 2026

This guide builds a phone OTP login for a Next.js App Router app: the user enters a mobile number, gets a 6-digit code in KakaoTalk, types it in, and is signed in. Your server holds the secret key and talks to K-OTP from Server Actions, so the browser never sees a key and your backend can trust the result.

You can build and test all of it with a test key: nothing is sent and no credits are used.

What you need

  • A Next.js 15 or later app with the App Router (the samples were type-checked against Next.js 16).
  • A K-OTP account. In the console, open your app, go to API keys, switch to the Test tab and create a test key. Use the sk_test_ secret key.
  • @k-otp/sdk 2.1.0 and server-only:
npm install @k-otp/sdk server-only

Put the key in .env.local. It has no NEXT_PUBLIC_ prefix, so it stays on the server:

K_OTP_SECRET_KEY=sk_test_...

How the flow works

  1. sendCode(phoneNumber) calls issue with a purpose ("login") and an idempotency key. K-OTP generates the code and delivers it by KakaoTalk AlimTalk. If AlimTalk cannot deliver it, the same code goes out by SMS automatically, at no extra cost.
  2. Your server stores the pending login: phone number, idempotency key and the issueId that issue returns.
  3. verifyCode(code) calls verify with that issueId. A wrong code is a normal result (verified: false with a reasonCode), not an exception. By default a code is valid for 3 minutes and allows 5 attempts.

1. The server client

// lib/otp.ts
import "server-only";

import { createOtpServerClient } from "@k-otp/sdk/server";

// The key is read when a request is made, so a missing variable fails that request, not the build.
export const otp = createOtpServerClient({
	apiKey: () => process.env.K_OTP_SECRET_KEY ?? "",
});

createOtpServerClient only accepts sk_ keys. Import it from server code only: server-only makes the build fail if a client component imports this file.

2. Where the pending login lives

The verify result is only worth something if it is bound to the phone number the code was sent to. Keep that binding on the server, not in a cookie the browser can edit. This example uses an in-memory map keyed by a session id; replace it with your session store or database (a Map is lost on restart and is not shared between instances).

// lib/pending-otp.ts
import "server-only";

export type PendingOtp = {
	phoneNumber: string;
	/** Saved before calling issue and reused when the same send is retried. */
	idempotencyKey: string;
	/** Set once issue succeeded. */
	issueId?: string;
	/** When the last code was sent (epoch ms), for the resend cooldown. */
	sentAt?: number;
};

// Example only: one process, lost on restart. Use your session store or database instead.
const store = new Map<string, PendingOtp>();

export const pendingOtp = {
	get: async (sessionId: string) => store.get(sessionId),
	set: async (sessionId: string, value: PendingOtp) => {
		store.set(sessionId, value);
	},
	delete: async (sessionId: string) => {
		store.delete(sessionId);
	},
};

3. Server Actions: send and verify

// app/login/actions.ts
"use server";

import { randomUUID } from "node:crypto";

import { isAmbiguousIssueError } from "@k-otp/sdk";
import { createIdempotencyKey, getIssueRequestErrorCode, isOtpApiError } from "@k-otp/sdk/server";
import { parseOtpPhoneNumber } from "@k-otp/sdk/ui";
import { cookies } from "next/headers";

import { otp } from "@/lib/otp";
import { type PendingOtp, pendingOtp } from "@/lib/pending-otp";
import { signIn } from "@/lib/session"; // your own session code

const RESEND_COOLDOWN_MS = 30_000;

export type SendCodeResult =
	| { ok: true; expiresAt: string; resendInMs: number }
	| {
			ok: false;
			error: "INVALID_PHONE" | "COOLDOWN" | "RATE_LIMITED" | "RETRY" | "UNAVAILABLE";
			retryInMs?: number;
	  };

export type VerifyCodeResult =
	| { ok: true }
	| {
			ok: false;
			error: "NO_CODE" | "MISMATCH" | "NEW_CODE_NEEDED" | "RATE_LIMITED" | "RETRY";
			attemptsRemaining?: number;
			retryInMs?: number;
	  };

async function getSessionId() {
	const jar = await cookies();
	const existing = jar.get("otp_sid")?.value;
	if (existing) return existing;
	const sessionId = randomUUID();
	jar.set("otp_sid", sessionId, {
		httpOnly: true,
		secure: process.env.NODE_ENV === "production",
		sameSite: "lax",
		path: "/",
	});
	return sessionId;
}

export async function sendCode(input: string): Promise<SendCodeResult> {
	// Korean mobile numbers only: "010-1234-5678" -> "01012345678", anything else -> undefined.
	const phoneNumber = parseOtpPhoneNumber(input).value;
	if (!phoneNumber) return { ok: false, error: "INVALID_PHONE" };
	// Add your own abuse checks here (CAPTCHA, limits per user or IP).

	const sessionId = await getSessionId();
	const previous = await pendingOtp.get(sessionId);

	if (previous?.sentAt && previous.phoneNumber === phoneNumber) {
		const waitMs = previous.sentAt + RESEND_COOLDOWN_MS - Date.now();
		if (waitMs > 0) return { ok: false, error: "COOLDOWN", retryInMs: waitMs };
	}

	// Retrying a send that never got an answer (same number, no issueId yet): reuse its key.
	// Anything else is a new attempt with a new key.
	const pending: PendingOtp =
		previous && !previous.issueId && previous.phoneNumber === phoneNumber
			? previous
			: { phoneNumber, idempotencyKey: createIdempotencyKey("login") };
	await pendingOtp.set(sessionId, pending); // save the key BEFORE calling issue

	try {
		const result = await otp.issue({
			phoneNumber: pending.phoneNumber,
			purpose: "login",
			idempotencyKey: pending.idempotencyKey,
		});
		await pendingOtp.set(sessionId, { ...pending, issueId: result.issueId, sentAt: Date.now() });
		return { ok: true, expiresAt: result.expiresAt, resendInMs: RESEND_COOLDOWN_MS };
	} catch (error) {
		if (!isOtpApiError(error)) throw error;

		if (isAmbiguousIssueError(error)) {
			// The code may already be on its way. Keep the key: a retry is replayed, not sent again.
			if (error.code === "TOO_MANY_REQUESTS") {
				return { ok: false, error: "RATE_LIMITED", retryInMs: error.retryAfterMs ?? 30_000 };
			}
			return { ok: false, error: "RETRY" };
		}

		// A definite rejection: nothing was sent, and the next send gets a new key.
		await pendingOtp.delete(sessionId);
		const requestError = getIssueRequestErrorCode(error); // e.g. TEMPLATE_NOT_AVAILABLE
		if (!requestError && error.code === "BAD_REQUEST") return { ok: false, error: "INVALID_PHONE" };
		// PAYMENT_REQUIRED (out of credit), UNAUTHORIZED, FORBIDDEN, ...: your problem, not the user's.
		console.error("K-OTP issue failed", requestError ?? error.code, error.requestId);
		return { ok: false, error: "UNAVAILABLE" };
	}
}

export async function verifyCode(code: string): Promise<VerifyCodeResult> {
	const sessionId = await getSessionId();
	const pending = await pendingOtp.get(sessionId);
	if (!pending?.issueId) return { ok: false, error: "NO_CODE" };
	if (!/^\d{6}$/.test(code)) return { ok: false, error: "MISMATCH" };

	try {
		const result = await otp.verify({ issueId: pending.issueId, code });
		// ALREADY_VERIFIED: an earlier verify of this session succeeded but its answer was lost.
		if (result.verified || result.reasonCode === "ALREADY_VERIFIED") {
			await pendingOtp.delete(sessionId);
			await signIn(pending.phoneNumber);
			return { ok: true };
		}
		if (result.reasonCode === "MISMATCH") {
			return { ok: false, error: "MISMATCH", attemptsRemaining: result.attemptsRemaining };
		}
		return { ok: false, error: "NEW_CODE_NEEDED" }; // EXPIRED, MAX_ATTEMPTS, REPLACED, NOT_FOUND
	} catch (error) {
		if (!isOtpApiError(error)) throw error;
		if (error.code === "TOO_MANY_REQUESTS") {
			return { ok: false, error: "RATE_LIMITED", retryInMs: error.retryAfterMs ?? 30_000 };
		}
		console.error("K-OTP verify failed", error.code, error.requestId);
		return { ok: false, error: "RETRY" }; // verify is safe to retry
	}
}

signIn stands for your own session code: create the session for the verified phone number there.

The idempotency key, step by step

issue sends a real message and uses a credit, so the SDK requires an idempotency key on every call. The rules in sendCode:

  • One key per send attempt. createIdempotencyKey("login") mints it, and it is saved before issue is called.
  • Unknown outcome, same key. After a timeout, a network error, a 503 or a 429, the code may already be on its way. isAmbiguousIssueError(error) returns true for exactly these cases. The pending entry keeps its key, so when the user presses the button again, K-OTP replays the first result instead of sending a second message or charging twice.
  • Definite answer, new key. After a success or a rejection (400, 401, 402, 403, 409), the next send is a new attempt. A changed phone number is a new attempt too: reusing a key with a different payload would be refused with 409 CONFLICT.

Note the imports: isAmbiguousIssueError comes from @k-otp/sdk; the server client and the other helpers come from @k-otp/sdk/server.

Errors the user can do something about

  • TOO_MANY_REQUESTS (429): a rate limit applied. error.retryAfterMs tells you how long to wait; the form turns it into a countdown.
  • BAD_REQUEST (400): usually the number. getIssueRequestErrorCode(error) separates the request-level codes (CHANNEL_NOT_SUPPORTED, TEMPLATE_NOT_AVAILABLE, WEB_OTP_NOT_SUPPORTED), which are bugs in your request, not the user's input.
  • PAYMENT_REQUIRED (402): your organization is out of credits. That is not the user's fault: show a generic message and alert yourself.

The resend cooldown is enforced on the server (30 seconds here), so it holds even if someone calls the action directly. Server Actions are public endpoints: add your own abuse controls where the comment says so.

4. The form

The form calls the two actions and uses OtpCodeInput from @k-otp/sdk/ui/react, a headless segmented code input with paste, keyboard navigation and autocomplete="one-time-code". It needs no key: it only edits a value.

// app/login/login-form.tsx
"use client";

import { OtpCodeInput } from "@k-otp/sdk/ui/react";
import { useRouter } from "next/navigation";
import { useEffect, useState, useTransition } from "react";

import { type SendCodeResult, type VerifyCodeResult, sendCode, verifyCode } from "./actions";

type SendError = Extract<SendCodeResult, { ok: false }>["error"];
type VerifyError = Extract<VerifyCodeResult, { ok: false }>["error"];

const SEND_ERRORS: Record<SendError, string> = {
	INVALID_PHONE: "Check the mobile number.",
	COOLDOWN: "Please wait before requesting another code.",
	RATE_LIMITED: "Too many requests. Please wait a moment.",
	RETRY: "We could not confirm the code was sent. Try again.",
	UNAVAILABLE: "Sending codes is unavailable right now. Please try later.",
};

const VERIFY_ERRORS: Record<VerifyError, string> = {
	NO_CODE: "Request a code first.",
	MISMATCH: "Wrong code.",
	NEW_CODE_NEEDED: "This code can no longer be used. Request a new one.",
	RATE_LIMITED: "Too many attempts. Please wait a moment.",
	RETRY: "Something went wrong. Try again.",
};

export function LoginForm() {
	const router = useRouter();
	const [phoneNumber, setPhoneNumber] = useState("");
	const [code, setCode] = useState("");
	const [codeSent, setCodeSent] = useState(false);
	const [message, setMessage] = useState("");
	const [waitUntil, setWaitUntil] = useState(0);
	const [now, setNow] = useState(() => Date.now());
	const [isPending, startTransition] = useTransition();

	// Tick once a second while a cooldown runs.
	useEffect(() => {
		if (waitUntil <= now) return;
		const timer = setTimeout(() => setNow(Date.now()), 1000);
		return () => clearTimeout(timer);
	}, [waitUntil, now]);
	const waitSeconds = Math.max(0, Math.ceil((waitUntil - now) / 1000));

	const send = () =>
		startTransition(async () => {
			const result = await sendCode(phoneNumber);
			setNow(Date.now());
			if (result.ok) {
				setCodeSent(true);
				setCode("");
				setWaitUntil(Date.now() + result.resendInMs);
				setMessage("We sent a code by KakaoTalk.");
				return;
			}
			if (result.retryInMs) setWaitUntil(Date.now() + result.retryInMs);
			setMessage(SEND_ERRORS[result.error]);
		});

	const verify = (value: string) =>
		startTransition(async () => {
			const result = await verifyCode(value);
			if (result.ok) {
				router.push("/");
				return;
			}
			setCode("");
			if (result.retryInMs) {
				setNow(Date.now());
				setWaitUntil(Date.now() + result.retryInMs);
			}
			const left = result.attemptsRemaining;
			setMessage(`${VERIFY_ERRORS[result.error]}${left ? ` ${left} attempts left.` : ""}`);
		});

	return (
		<form onSubmit={(event) => event.preventDefault()}>
			<label htmlFor="phone">Mobile number</label>
			<input
				id="phone"
				type="tel"
				autoComplete="tel"
				value={phoneNumber}
				onChange={(event) => setPhoneNumber(event.target.value)}
			/>
			<button type="button" onClick={send} disabled={isPending || waitSeconds > 0}>
				{waitSeconds > 0 ? `Resend in ${waitSeconds}s` : codeSent ? "Resend code" : "Send code"}
			</button>

			{codeSent && (
				<OtpCodeInput
					aria-label="Verification code"
					locale="en"
					value={code}
					onValueChange={setCode}
					onComplete={verify}
					readOnly={isPending}
					autoFocus
				/>
			)}

			<p role="status">{message}</p>
		</form>
	);
}
// app/login/page.tsx
import { LoginForm } from "./login-form";

export default function LoginPage() {
	return (
		<main>
			<h1>Log in</h1>
			<LoginForm />
		</main>
	);
}

The component is unstyled. For the default look, import @k-otp/sdk/ui/theme.css, or style the data-k-otp attributes yourself; see UI components.

Try it in test mode

With an sk_test_ key, K-OTP sends nothing and uses no credits. It only accepts test numbers, whose last two digits pick a scenario, and every test code is 000000.

  • 010-0000-0000: the code counts as delivered, and 000000 signs you in.
  • 010-0000-0005: every code is wrong, so you see the attempts go down.
  • 010-0000-0006: a 429, so the button shows a 60-second countdown.
  • 010-0000-0007: a 402, so the user sees the generic "unavailable" message and your server log shows the error.
  • 010-0000-0009: a 503, so the user sees "could not confirm". Pressing send again retries with the same key instead of starting a new send.

A real number with a test key is refused with 400 (TEST_NUMBER_REQUIRED), which the form shows as "check the number". The console's Test inbox lists your test issues. All scenarios are in the test mode guide.

Before you go live

  • Replace sk_test_ with a live sk_ key in your production environment. Live keys reject test numbers.
  • Send a few real codes to your team's phones. Each live send costs 1 credit, including the automatic SMS fallback; verify calls are free.
  • Move the pending state to your session store or database.
  • Keep your own abuse controls in front of sendCode.

Prefer a ready-made form?

OtpForm from @k-otp/sdk/ui/react renders the whole flow (phone field, send and resend, code input, countdown, messages) and calls K-OTP directly from the browser with a pk_ public key. That is quicker to set up, but browser-side verification only tells the browser that the code matched. If your server grants access based on it, confirm on the server with getStatus({ issueId }) and an sk_ key. The server-driven flow above avoids that step.

Read next