PKCE and public clients
How to run Sign in with Insy from a single-page app, a mobile app or anything else that cannot keep a client secret — using PKCE with S256.
A public client is one whose code ships to the user: a single-page app, a mobile app, a desktop
app, a browser widget. Anything you put in that bundle is readable, so a public client has no
client_secret at all. PKCE (Proof Key for Code Exchange) replaces the secret with a value your
client invents per sign-in and proves knowledge of at the token exchange.
What makes a client public
The client type is a property of the client and is never taken from the request. Register one as
public at
Account → Developer → OAuth clients and you get
a client_id (format oac_ plus 24 hex characters) and no secret.
| Confidential client | Public client | |
|---|---|---|
client_secret |
Issued once, required in the token request | None exists |
| PKCE | Optional | Required |
| Where it runs | Your server | The user’s device or browser |
Two rules follow, and both are enforced:
- A public client must not send a non-empty
client_secret. Any non-empty value, including one copied from another client, fails the token request withinvalid_clientand HTTP 401. An empty string is treated as absent, but omit the field entirely. - A public client must send
code_challengeandcode_challenge_method=S256on the authorization request, and the matchingcode_verifieron the token request.
Confidential clients may use PKCE as well. It is optional for them, but there is no reason to skip it — if you send a challenge, remember to send the verifier.
The mechanics
Generate a code_verifier
A high-entropy random string, 43 to 128 characters long. Generate it fresh for every sign-in attempt and never reuse it.
Derive the code_challenge
Take the SHA-256 digest of the ASCII bytes of the verifier and base64url-encode it without
padding: code_challenge = base64url(SHA-256(code_verifier)). Base64url means the standard
alphabet with + replaced by -, / replaced by _, and trailing = stripped.
Send the challenge to /oauth/authorize
Add code_challenge and code_challenge_method=S256 to the query string. S256 is the only
supported method — plain is not accepted.
Keep the verifier until the exchange
Store it somewhere that survives the redirect or the popup round-trip, and that no other origin
can read — sessionStorage in a browser, the platform keychain or an in-memory session in a
native app. If you lose the verifier you cannot complete the exchange, and the code is useless on
its own.
Present the verifier at /oauth/token
Send code_verifier in the token request body alongside code and redirect_uri. Insy hashes
it and compares against the challenge it stored at authorize time.
A complete browser example
The two values come from the Web Crypto API, which is available only in a secure context:
an HTTPS page, or http://localhost during development. On a plain-HTTP page crypto.subtle is
undefined and the flow cannot start. (This is exactly what the widget reports as
insecure_context.)
/** Base64url — the URL-safe alphabet, no padding. */
export function base64url(bytes) {
const binary = String.fromCharCode(...new Uint8Array(bytes));
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
/** 32 random bytes render as 43 base64url characters — the shortest legal verifier. */
export function createCodeVerifier() {
return base64url(crypto.getRandomValues(new Uint8Array(32)));
}
/** code_challenge = base64url(SHA-256(code_verifier)) */
export async function deriveCodeChallenge(verifier) {
if (!globalThis.crypto?.subtle) {
throw new Error("Web Crypto requires a secure context (HTTPS or localhost)");
}
const digest = await crypto.subtle.digest(
"SHA-256",
new TextEncoder().encode(verifier),
);
return base64url(digest);
}
Starting the flow
import { base64url, createCodeVerifier, deriveCodeChallenge } from "./pkce.js";
const CLIENT_ID = "oac_9f2c41ab77e05d3612b8c4ef";
const REDIRECT_URI = "https://app.example.com/auth/insy/callback";
export async function startLogin() {
const verifier = createCodeVerifier();
const challenge = await deriveCodeChallenge(verifier);
const state = base64url(crypto.getRandomValues(new Uint8Array(16)));
// Both values have to survive the trip to Insy and back.
sessionStorage.setItem("insy_verifier", verifier);
sessionStorage.setItem("insy_state", state);
const url = new URL("https://api.insy.io/oauth/authorize");
url.searchParams.set("response_type", "code");
url.searchParams.set("client_id", CLIENT_ID);
url.searchParams.set("redirect_uri", REDIRECT_URI);
url.searchParams.set("scope", "memberships.read");
url.searchParams.set("state", state);
url.searchParams.set("code_challenge", challenge);
url.searchParams.set("code_challenge_method", "S256");
window.location.assign(url.toString());
}
scope is required — Insy rejects an authorization request without it, even though RFC 6749
treats it as optional. The full list is memberships.read and products.read.
Completing the exchange
export async function completeLogin() {
const params = new URLSearchParams(window.location.search);
const verifier = sessionStorage.getItem("insy_verifier");
const expectedState = sessionStorage.getItem("insy_state");
sessionStorage.removeItem("insy_verifier");
sessionStorage.removeItem("insy_state");
if (params.get("error")) {
throw new Error(params.get("error")); // access_denied, invalid_scope, ...
}
if (!expectedState || params.get("state") !== expectedState) {
throw new Error("State mismatch");
}
if (!verifier) {
throw new Error("Verifier lost — the sign-in has to be restarted");
}
const res = await fetch("https://api.insy.io/oauth/token", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
grant_type: "authorization_code",
client_id: "oac_9f2c41ab77e05d3612b8c4ef",
// No client_secret. A public client that sends one gets invalid_client.
code: params.get("code"),
redirect_uri: "https://app.example.com/auth/insy/callback",
code_verifier: verifier,
}),
});
// The token endpoint answers 201, not 200 — test for any 2xx.
if (!res.ok) {
const body = await res.json();
throw new Error(`${body.error}: ${body.error_description}`);
}
return res.json();
}
{
"access_token": "a1b7c3d9e5f20486b3c1d7e9f5a20486b3c1d7e9",
"refresh_token": "5e2c9a71d84f0b36c7a1e93f5b820d4ec6a37f18",
"token_type": "Bearer",
"expires_in": 3600,
"scope": "memberships.read"
}
Handling tokens on the client
Tokens issued to a public client land on the user’s device. Treat them as user-scoped credentials.
- The access token expires after an hour. The refresh token lasts 30 days on a sliding window, and refreshing revokes both the old refresh token and the old access token immediately.
- Refresh with
grant_type: "refresh_token",client_idandrefresh_token— again with noclient_secret. - Avoid persisting the refresh token in
localStorageif you can hold it in memory instead. Anything reachable by injected script on your origin is reachable by an attacker’s script. - Do privileged work on your own server. A public client is a way to identify the user, not a place to run trusted logic.
- There is no revocation path for a public client:
POST /oauth/revokerequiresclient_secret, and a public client presenting one is rejected withinvalid_client. Drop the tokens on your side and let the access token expire after an hour.
Common failures
invalid_client on the token requestHTTP 401
Either a public client sent client_secret, or a confidential client did not. The client
type is fixed at registration — check which kind you were issued rather than changing the
request until something works.
invalid_grant on the token requestHTTP 400
The code is expired (they live 60 seconds), already used, or the code_verifier does not hash
to the code_challenge you sent. Confirm you are base64url-encoding the raw digest bytes, not
a hex string of them.
crypto.subtle is undefined
The page is not in a secure context. Serve it over HTTPS, or develop against
http://localhost rather than a LAN IP address.
HTTP 400 from /oauth/authorize instead of a redirect
Insy refuses to redirect when it cannot trust the destination: an unknown client_id or a
redirect_uri that does not exactly match a registered one. Every environment you run in has
to be registered.
For a public client there is a third cause, and it is the common one: a missing
code_challenge, which is rejected with invalid_request before the redirect URI is trusted.
A challenge outside 43–128 characters, or a code_challenge_method other than S256, is
rejected the same way.