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/sdk2.1.0 andserver-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
sendCode(phoneNumber)callsissuewith 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.- Your server stores the pending login: phone number, idempotency key and the
issueIdthatissuereturns. verifyCode(code)callsverifywith thatissueId. A wrong code is a normal result (verified: falsewith areasonCode), 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 beforeissueis called. - Unknown outcome, same key. After a timeout, a network error, a
503or a429, the code may already be on its way.isAmbiguousIssueError(error)returnstruefor 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 with409 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.retryAfterMstells 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, and000000signs you in.010-0000-0005: every code is wrong, so you see the attempts go down.010-0000-0006: a429, so the button shows a 60-second countdown.010-0000-0007: a402, so the user sees the generic "unavailable" message and your server log shows the error.010-0000-0009: a503, 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 livesk_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.