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:
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.testWhoever 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 sageWhat that means for you:
- Register every URI you actually use — development, staging, production, each subdomain on its own line.
- Send the same
redirect_uriat authorize and at token exchange. We compare it twice. https://only, excepthttp://localhostwhile 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
httpOnlycookie, 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.
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
kidnames in our JWKS. algisES256. Never acceptnone, and never let the token choose its own algorithm.issis the issuer you expect;audis yourclient_id.exphas not passed, allowing a few seconds of clock skew.noncematches 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/userinfostarts 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.