Skip to content
Developers

Authentication

The authorization code flow, PKCE, scopes, and what each token is for.

Authorization code flow

The only flow we support. No implicit, no hybrid, no password grant — each of those carries a secret through somewhere it should not be.

1. Send the person to the authorize endpoint

text
https://visitkorat.com/oauth/authorize
  ?client_id=your-app-3f2a91c4
  &redirect_uri=https%3A%2F%2Fexample.com%2Fauth%2Fcallback
  &response_type=code
  &scope=openid%20email%20profile
  &state=<random, per request>
  &code_challenge=<base64url(sha256(verifier))>
  &code_challenge_method=S256
  &nonce=<random, per request>
  &terms_version=2026-08-01
  &privacy_version=2026-08-01

terms_version and privacy_version are the versions of your documents, not ours. We store them with the consent record, and if either changes the person is asked again — which is what makes that record mean something under PDPA (see consent and PDPA).

2. Receive the code at your callback

text
https://example.com/auth/callback?code=8f14e45f...&state=<the value you sent>

Compare state before doing anything else. If it does not match, discard the request.

3. Exchange the code — from your server

bash
curl -s -X POST https://visitkorat.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "grant_type=authorization_code" \
  -d "code=8f14e45f..." \
  -d "redirect_uri=https://example.com/auth/callback" \
  -d "code_verifier=$CODE_VERIFIER"
json
{
  "access_token": "vkat_dev_9f2b1c...",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "openid email profile",
  "id_token": "eyJhbGciOiJFUzI1NiIsImtpZCI6..."
}

PKCE

Required for every application, public and confidential. For a public client it is the only thing standing between an intercepted code and somebody’s account; for a confidential one it costs a client library nothing and closes code interception as well.

sequenceDiagram
  autonumber
  participant A as Your server
  participant B as Browser
  participant V as Visit Korat
  Note over A: verifier = random 43-128 chars<br/>challenge = base64url(SHA-256(verifier))
  A->>B: Redirect with code_challenge
  B->>V: GET /oauth/authorize
  V-->>B: Redirect back with code
  B->>A: code
  Note over A: The verifier never left this server
  A->>V: POST /oauth/token with code + code_verifier
  V->>V: SHA-256(verifier) == stored challenge?
  V->>A: Tokens, only if it matches
The verifier never leaves your server.

Only S256 is accepted. plain is refused, because a challenge that equals the verifier protects against nothing that an attacker able to read the redirect cannot also read.

typescript
// Generate per request. Keep the verifier server-side (or in sessionStorage for an SPA).
const bytes = crypto.getRandomValues(new Uint8Array(32));
const verifier = base64url(bytes);              // 43 characters

const digest = await crypto.subtle.digest("SHA-256", new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));

function base64url(b: Uint8Array): string {
  return btoa(String.fromCharCode(...b))
    .replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}

Check your implementation against the test vector in RFC 7636: the verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk must produce the challenge E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM.

Scopes

ScopeWhat it grants
openidRequired. Produces the id_token and a stable sub.
emailemail and email_verified
profilename — display name, may be null
offline_accessRefresh tokens. Granted by Visit Korat on request, not self-service.

Ask for what you use. The consent screen lists them to the person verbatim, and a long list is a reason to decline.

ID token

A JWT signed with ES256, valid for five minutes. It asserts that this person just signed in successfully. It is not a licence to access anything.

jsonDisplay names are Thai — render accordingly.
{
  "iss": "https://visitkorat.com",
  "sub": "6f3f0a5e-3f7a-4a6b-9c1e-2b2f9e4a1d77",
  "aud": "your-app-3f2a91c4",
  "exp": 1788000300,
  "iat": 1788000000,
  "auth_time": 1788000000,
  "azp": "your-app-3f2a91c4",
  "nonce": "n-0S6_WzA2Mj",
  "email": "[email protected]",
  "email_verified": true,
  "name": "สมชาย ใจดี"
}

sub is this person’s permanent identifier for your application. Key your records on it, not on the email address — people change those.

How to verify the signature is in validating the ID token.

Access token

An opaque string, not a JWT. Do not try to decode it; there is nothing inside. Valid for one hour, and useful only for calling /oauth/userinfo.

It is opaque so that it can be revoked for real. The second somebody disconnects your application, the token stops working. A signed token would keep working until it expired, whatever they did.

bash
curl -s https://visitkorat.com/oauth/userinfo \
  -H "Authorization: Bearer $ACCESS_TOKEN"

Refresh token

Valid for thirty days and single use. Every refresh returns a new one and kills the old — rotation.

⚠️ Presenting a token that was already used means two parties hold the same token. We cannot tell which of you is the thief, so every token from that authorization is revoked and the person must sign in again. A lost session is recoverable; a thief with a self-renewing credential is not.

Two things must both be true for you to receive one: Visit Korat has enabled offline_access for your application, and the person consented to it on the consent screen.

bash
curl -s -X POST https://visitkorat.com/oauth/token \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "grant_type=refresh_token" \
  -d "refresh_token=$REFRESH_TOKEN"

Logout and revocation

When somebody signs out of your app, do two things: revoke the token, then clear your own session.

bash
curl -s -X POST https://visitkorat.com/oauth/revoke \
  -H "Content-Type: application/x-www-form-urlencoded" \
  -u "$CLIENT_ID:$CLIENT_SECRET" \
  -d "token=$REFRESH_TOKEN"

Revoking a refresh token takes its access tokens with it — otherwise “sign out” leaves an hour of working credential behind.

The same happens, without you asking, when the person disconnects your application themselves:

flowchart LR
  U["Person clicks<br/>Disconnect"] --> C["Consent row revoked"]
  C --> A["Access tokens revoked"]
  C --> R["Refresh tokens revoked"]
  A --> UI["/oauth/userinfo<br/>401 in the same second"]
  R --> RF["Refresh returns<br/>invalid_grant"]
  C -.cannot reach.-> S["The session your app<br/>minted for itself"]
  classDef clay fill:#F7EFE8,stroke:#A86B3D,color:#292723;
  classDef sage fill:#EEF3ED,stroke:#7FA378,color:#292723;
  classDef lagoon fill:#EAF1F5,stroke:#3B7FA1,color:#292723;
  class UI sage
  class RF sage
  class S clay
What withdrawing consent cuts, and what it cannot reach.

To send somebody to sign out of Visit Korat as well, redirect the browser to /oauth/logout with id_token_hint and a registered post_logout_redirect_uri.

⚠️ That is not single logout. It ends the Visit Korat session and nothing else — other applications the person is signed into stay signed in. There is no front-channel or back-channel logout, and the discovery document says so. Do not tell people they have been signed out everywhere, because they have not.