ข้ามไปยังเนื้อหา
Developers

Security

What you have to get right, and why we are strict about each of them.

This is not a generic list of good practice. Each item below is a real hole that has been used to take over accounts on somebody else’s “Sign in with X”, and each one is why some part of our implementation is stricter than the specification requires.

Redirect URI

Your redirect_uri is compared against your registered list byte for byte. No wildcards, no domain-only matching, no forgiving a trailing slash.

That strict because a “starts with” check is also true of these:

text
Registered:  https://example.com/callback

A prefix check would also accept:
  https://example.com.attacker.test/callback      ← an entirely different domain
  https://example.com/callback/../../evil          ← path traversal
  https://[email protected]/callback           ← the real host is evil.test

Whoever chooses the destination receives the authorization code. That is an account takeover, not an inconvenience.

The order the checks run in is itself the protection:

flowchart TD
  Q["GET /oauth/authorize"] --> C{"client_id known,<br/>approved and active?"}
  C -->|No| E1["Error page.<br/>Redirect nowhere."]
  C -->|Yes| R{"redirect_uri registered,<br/>matched exactly?"}
  R -->|No| E2["Error page.<br/>Redirect nowhere."]
  R -->|Yes| V{"PKCE, state, scope,<br/>document versions valid?"}
  V -->|No| E3["Redirect back with<br/>?error= — the destination<br/>is verified now"]
  V -->|Yes| L["Sign in, then consent"]
  L --> OK["Redirect back<br/>with code + state"]
  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 E1 clay
  class E2 clay
  class E3 lagoon
  class OK sage
Until the destination is verified, an error cannot be reported by redirecting to it.

What that means for you:

  • Register every URI you actually use — development, staging, production, each subdomain on its own line.
  • Send the same redirect_uri at authorize and at token exchange. We compare it twice.
  • https:// only, except http://localhost while developing.
  • To return somebody to the page they were on, put it in state, not in the redirect URI.

state and CSRF

We require state, although RFC 6749 only recommends it, because we cannot check it for you — only you know what you sent.

Without it, an attacker starts the flow with their own account and tricks a victim into opening the resulting callback URL. The victim ends up signed into your app as the attacker, and everything they do next is visible to them.

  • Random per request, at least 16 bytes.
  • Bound to the person’s session — an httpOnly cookie, not a module-level variable.
  • Compared before you touch the code. Comparing it afterwards is comparing it too late.
  • Single use. Never accept the same value twice.

nonce

Send a nonce with the authorize request and we put it in the ID token. Comparing it proves the token answers this request rather than being an older one replayed.

It is optional — but if you do not send one, the claim is absent entirely rather than empty, so nothing can mistake a missing protection for a present one.

Validating the ID token

The ID token is a JWT signed with ES256. Never decode and trust it — an unverified JWT is JSON that anybody can write.

typescript
import { createRemoteJWKSet, jwtVerify } from "jose";

// Module scope: cached, and refetched automatically when an unknown kid appears
// — which is what a key rotation looks like from your side.
const JWKS = createRemoteJWKSet(new URL("https://visitkorat.com/.well-known/jwks.json"));

const { payload } = await jwtVerify(idToken, JWKS, {
  issuer: "https://visitkorat.com",                    // must match byte for byte
  audience: process.env.VK_CLIENT_ID!,    // must be your client_id
  clockTolerance: "5s",
});

if (payload.nonce !== expectedNonce) throw new Error("nonce mismatch");

Everything that must be checked — a good library does all of it:

  • The signature, against the key the kid names in our JWKS.
  • alg is ES256. Never accept none, and never let the token choose its own algorithm.
  • iss is the issuer you expect; aud is your client_id.
  • exp has not passed, allowing a few seconds of clock skew.
  • nonce matches what you sent.

The access_token needs no validation — it is opaque. Call /oauth/userinfo and see what we say.

Storing your client secret

  • Server side only. If it can reach a browser, you should be using a public client with PKCE instead.
  • In a secret manager or an environment variable, never in git — and a different value in development and production.
  • Never logged, even while debugging. Never in a URL: query strings are written to the access log of every proxy the request passes through.
  • If it leaks, issue a new one at /developers/apps. The old one stops immediately.

We store only the SHA-256 of your secret. Nobody can read it back, including us, which is why it is shown exactly once.

⚠️ A confidential application that calls /oauth/token from a browser is refused, not quietly allowed — a secret that can travel from a browser is a secret sitting in a bundle anybody can open.

Consent and PDPA

We record who consented, to which application, against which version of your documents, when, and what was shared.

That is why terms_version and privacy_version are required. Consent under PDPA is consent to a stated purpose; when the statement changes, the old consent no longer covers it.

What that puts on you:

  • Change what your documents say, change the version. We will ask the person again next time. Edit the wording without moving the number and every consent record points at text that no longer exists.
  • Request only the scopes you use. The person sees that list.
  • Honour withdrawal. People can disconnect your app at any time from /account/connections. Your tokens are revoked immediately and /oauth/userinfo starts answering 401 — delete the data you stored. Us closing the tap does not remove the copy you hold.
  • Make your policy match what you actually do. A reviewer here opens that link.

We send sub, the email address and the display name. Never order history, postal addresses or phone numbers, whatever scope is requested.