Pick the one that matches your app. There is really only one question behind the choice: can your code keep a secret? If it can, use confidential. If it cannot, use public.
Web app with a backend
Confidential client — the client_secret stays on your server. This is the safest arrangement, and the one to choose whenever you can.
import { redirect } from "next/navigation";
import { cookies } from "next/headers";
const ISSUER = "https://visitkorat.com";
function base64url(b: Uint8Array) {
return btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export async function GET() {
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
const nonce = base64url(crypto.getRandomValues(new Uint8Array(16)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));
// httpOnly: the browser must not be able to read these back, and 10 minutes
// is longer than any real login takes.
const jar = await cookies();
const opts = { httpOnly: true, secure: true, sameSite: "lax" as const, path: "/", maxAge: 600 };
jar.set("vk_verifier", verifier, opts);
jar.set("vk_state", state, opts);
jar.set("vk_nonce", nonce, opts);
const url = new URL(ISSUER + "/oauth/authorize");
url.searchParams.set("client_id", process.env.VK_CLIENT_ID!);
url.searchParams.set("redirect_uri", process.env.VK_REDIRECT_URI!);
url.searchParams.set("response_type", "code");
url.searchParams.set("scope", "openid email profile");
url.searchParams.set("state", state);
url.searchParams.set("nonce", nonce);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
url.searchParams.set("terms_version", "2026-08-01");
url.searchParams.set("privacy_version", "2026-08-01");
redirect(url.toString());
}import { cookies } from "next/headers";
import { createRemoteJWKSet, jwtVerify } from "jose";
const ISSUER = "https://visitkorat.com";
// Module scope: the key set is cached across requests and refetched only when
// an unknown kid appears — which is what a key rotation looks like from here.
const JWKS = createRemoteJWKSet(new URL(ISSUER + "/.well-known/jwks.json"));
export async function GET(req: Request) {
const url = new URL(req.url);
const jar = await cookies();
// The app may have declined, or the request may have been rejected.
const error = url.searchParams.get("error");
if (error) return new Response(url.searchParams.get("error_description") ?? error, { status: 400 });
// ⚠️ Compare state BEFORE anything else. This is the CSRF check, and it is
// worthless if it happens after the code has already been spent.
const state = url.searchParams.get("state");
if (!state || state !== jar.get("vk_state")?.value) {
return new Response("state mismatch", { status: 400 });
}
const res = await fetch(ISSUER + "/oauth/token", {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: "Basic " + btoa(
`${encodeURIComponent(process.env.VK_CLIENT_ID!)}:${encodeURIComponent(process.env.VK_CLIENT_SECRET!)}`,
),
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: url.searchParams.get("code")!,
redirect_uri: process.env.VK_REDIRECT_URI!,
code_verifier: jar.get("vk_verifier")!.value,
}),
});
if (!res.ok) {
const err = await res.json();
return new Response(err.error_description ?? err.error, { status: 400 });
}
const tokens = await res.json();
const { payload } = await jwtVerify(tokens.id_token, JWKS, {
issuer: ISSUER,
audience: process.env.VK_CLIENT_ID!,
});
if (payload.nonce !== jar.get("vk_nonce")?.value) {
return new Response("nonce mismatch", { status: 400 });
}
// Key your user record on payload.sub — never on the email, people change those.
await upsertUser({ vkSub: payload.sub as string, email: payload.email as string });
for (const c of ["vk_verifier", "vk_state", "vk_nonce"]) jar.delete(c);
await startSession(payload.sub as string);
// ⚠️ A path, not new URL("/", req.url). Reading the query off req.url above is
// fine, but building a redirect from it is not: behind a proxy — Cloud Run,
// Vercel, any load balancer — req.url carries the internal host, so that
// redirect sends people somewhere like https://0.0.0.0:8080/. It fails
// silently, because the status code is correct and only a human sees where
// they landed. A relative Location is resolved against the address bar
// (RFC 7231 §7.1.2).
return new Response(null, { status: 303, headers: { Location: "/" } });
}Single-page app
Public client — no secret, because code running in a browser cannot hold one. Request a public application and list your origin under allowed origins.
⚠️ /oauth/token answers CORS only for origins you registered, one at a time — there is no wildcard. If the browser blocks the request, check the origin matches exactly, scheme and port included.
const ISSUER = "https://visitkorat.com";
const CLIENT_ID = "your-spa-91c4a3f2";
const REDIRECT = window.location.origin + "/callback";
function base64url(b: Uint8Array) {
return btoa(String.fromCharCode(...b)).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export async function login() {
const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
const nonce = base64url(crypto.getRandomValues(new Uint8Array(16)));
const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
// sessionStorage, not localStorage: this is per-tab, single-use scratch data
// and it should not outlive the tab that created it.
sessionStorage.setItem("vk_verifier", verifier);
sessionStorage.setItem("vk_state", state);
sessionStorage.setItem("vk_nonce", nonce);
const url = new URL(ISSUER + "/oauth/authorize");
Object.entries({
client_id: CLIENT_ID,
redirect_uri: REDIRECT,
response_type: "code",
scope: "openid email",
state, nonce,
code_challenge: base64url(new Uint8Array(digest)),
code_challenge_method: "S256",
terms_version: "2026-08-01",
privacy_version: "2026-08-01",
}).forEach(([k, v]) => url.searchParams.set(k, v));
window.location.assign(url.toString());
}export async function handleCallback() {
const params = new URLSearchParams(window.location.search);
if (params.get("state") !== sessionStorage.getItem("vk_state")) {
throw new Error("state mismatch");
}
const res = await fetch("https://visitkorat.com/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: params.get("code")!,
redirect_uri: window.location.origin + "/callback",
code_verifier: sessionStorage.getItem("vk_verifier")!,
client_id: "your-spa-91c4a3f2",
}),
});
const tokens = await res.json();
if (!res.ok) throw new Error(tokens.error_description ?? tokens.error);
["vk_verifier", "vk_state", "vk_nonce"].forEach((k) => sessionStorage.removeItem(k));
return tokens; // { access_token, id_token, expires_in, ... }
}Keep the access_token in memory if you can. A token in localStorage is readable by any XSS that reaches the same page.
If you already have a backend, the safer arrangement is a confidential client with the SPA talking to your own server (backend-for-frontend) — then no token touches the browser at all.
Mobile app
Public client, as for an SPA. Open the authorize URL in the system browser — ASWebAuthenticationSession on iOS, Custom Tabs on Android — never an embedded WebView. A WebView can read everything the person types, so they have no way to know whether they are really talking to us.
Register a universal link / app link (https://) as the redirect URI where you can. Nothing stops another app claiming a custom scheme like myapp://, and we accept only https:// and http://localhost anyway.
let session = ASWebAuthenticationSession(
url: authorizeURL, // built the same way as the SPA example
callbackURLScheme: nil // universal link
) { callbackURL, error in
guard let callbackURL, error == nil else { return }
// 1. compare state 2. POST /oauth/token with code_verifier 3. verify id_token
}
session.prefersEphemeralWebBrowserSession = false // reuse the browser session the person already has
session.start()Using a client library
The examples above are hand-rolled so you can see what happens. In production, use a library that has been through conformance testing: clock skew, an aud that arrives as an array, refetching JWKS on an unknown key — those are where hand-written implementations go wrong.
import * as client from "openid-client";
// every endpoint comes from discovery — nothing hardcoded but the issuer
const config = await client.discovery(
new URL("https://visitkorat.com"),
process.env.VK_CLIENT_ID!,
process.env.VK_CLIENT_SECRET!,
);
const verifier = client.randomPKCECodeVerifier();
const challenge = await client.calculatePKCECodeChallenge(verifier);
const nonce = client.randomNonce();
const state = client.randomState();
const authUrl = client.buildAuthorizationUrl(config, {
redirect_uri: process.env.VK_REDIRECT_URI!,
scope: "openid email profile",
code_challenge: challenge,
code_challenge_method: "S256",
state, nonce,
// Visit Korat specific: the versions of YOUR documents, stored with the consent (PDPA)
terms_version: "2026-08-01",
privacy_version: "2026-08-01",
});
// ...after the person lands back on your callback
const tokens = await client.authorizationCodeGrant(config, currentUrl, {
pkceCodeVerifier: verifier,
expectedState: state,
expectedNonce: nonce,
});
const claims = tokens.claims(); // signature and claims already verifiedOthers that work as-is: next-auth / @auth/core (configure a generic OIDC provider pointed at our issuer), pyoidc and authlib for Python, league/oauth2-client for PHP, and AppAuth for iOS and Android.
Always send terms_version and privacy_version. No standard library knows about them, so you have to pass them as extra authorization parameters — and the request is refused without them (see troubleshooting).