API Security for Bangkok Businesses: Protecting Next.js Routes That Handle Bookings and Payments
19 May 2026 · by Yunmin Shin
Why API Security Deserves Specific Attention
A Next.js clinic booking system exposes API surface through Route Handlers, Server Actions, and any third-party API it proxies — the PromptPay payment webhook, the LINE OA message webhook, the internal admin endpoints staff use to view patient booking history. Each is a potential attack vector, and the stakes are higher than a typical marketing site: a booking API holds patient names and phone numbers, a payment webhook touches real money, and both fall under Thailand's Personal Data Protection Act (PDPA), which puts legal obligations on how that data is collected, stored, and secured.
The good news is that securing these routes follows a small, repeatable set of patterns. None of them are exotic — they just need to actually be applied to every route that touches money or personal data, not just the obvious ones.
How Do You Authenticate API Requests?
Every route that returns or modifies user-specific data — a patient's booking history, a clinic staff dashboard, an order in a restaurant's LINE ordering system — must verify who's calling. With Supabase:
const supabase = await createClient();
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}
Always use getUser(), never getSession(), in API routes. getUser() validates the JWT against Supabase's servers on every call; getSession() just decodes the cookie locally, so a forged or stale token can pass getSession() while failing getUser().
For server-to-server calls — your own backend job calling an internal endpoint, or a partner integration hitting your API directly rather than through a webhook — use a pre-shared secret verified with a constant-time comparison (crypto.timingSafeEqual) so response-time differences can't leak whether a guessed token was close to correct.
How Do You Verify Webhooks Specifically?
Webhooks are API routes the public internet can call with no login at all — their security depends entirely on signature verification, and this is the single most commonly skipped step in Bangkok integrations:
import crypto from "crypto";
export async function POST(request: Request) {
const rawBody = await request.text();
const signature = request.headers.get("x-line-signature");
const expected = crypto
.createHmac("sha256", process.env.LINE_CHANNEL_SECRET!)
.update(rawBody)
.digest("base64");
if (signature !== expected) {
return Response.json({ error: "Invalid signature" }, { status: 401 });
}
const event = JSON.parse(rawBody);
// safe to process now
}
Apply the same discipline to the PromptPay/Omise payment webhook — verify its signature before trusting the payload, and never mark an order as paid based on a client-side redirect alone. A client-side "payment successful" page can be reached by simply navigating to the URL; only a verified server-side webhook proves the money actually moved.
How Do You Validate and Sanitize Input?
Validate everything at the boundary with Zod, and reject unexpected fields rather than silently dropping them:
const schema = z.object({
amount: z.number().int().positive().max(10_000_000),
bookingId: z.string().uuid(),
note: z.string().max(200).optional(),
}).strict();
const result = schema.safeParse(await request.json());
if (!result.success) {
return Response.json({ error: "Invalid input" }, { status: 400 });
}
.strict() matters more than it seems — an extra isAdmin: true field silently accepted by a loose schema is exactly the kind of bug that turns into a privilege escalation report.
How Do You Configure CORS?
CORS controls which origins can call your API from a browser. A clinic's public booking widget embedded on a partner site, and its own internal admin dashboard, have very different trust levels — don't give them the same access:
const allowedOrigins = ["https://yourclinic.com", "https://admin.yourclinic.com"];
const origin = request.headers.get("origin") ?? "";
if (allowedOrigins.includes(origin)) {
headers.set("Access-Control-Allow-Origin", origin);
}
Never use Access-Control-Allow-Origin: * on an authenticated route. Wildcard CORS combined with cookie-based sessions opens the door to cross-site request forgery — a malicious page could trigger authenticated requests against your API using a logged-in user's own session.
How Do You Prevent Abuse with Rate Limiting?
Tier limits by how expensive or sensitive the endpoint is:
- Login and OTP endpoints: 5 attempts per 15 minutes per IP
- AI chatbot / Claude-backed endpoints: 10 requests per minute per session
- Public booking form: 20 requests per minute per IP
- General authenticated API routes: 60 requests per minute per user
Return a Retry-After header on 429 responses so legitimate clients — including a webhook sender retrying after a timeout — back off correctly instead of hammering the endpoint.
What Secrets Management Practices Are Essential?
- Store all secrets — Anthropic key, Omise secret key, LINE channel secret, Supabase service role key — in environment variables, never in committed code
- Use separate keys for development and production; a leaked test key is an inconvenience, a leaked production payment key is an incident
- Rotate immediately if a secret is exposed in a commit, a log, or a screenshot shared for debugging
- Enable GitHub's secret scanning and push protection so common key formats are blocked before they land in history
- Grant minimum-necessary permissions — a booking API's database role shouldn't be able to drop tables or read other clinics' data if you're running a multi-tenant setup
Under PDPA, being able to show that patient and payment data was handled with reasonable technical safeguards — encrypted in transit, access-controlled, not logged in plaintext — isn't just good practice, it's the baseline a Thai business is expected to meet.
Ready to Build Something Fast?
Get a free quote. We reply within 24 hours.
Ready to build something fast and scalable?
Get a free project quote. We reply within 24 hours.
Get a Free Quote →