Skip to content
Insy
For developers
Esc
navigateopen⌘Jpreview
On this page

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 with invalid_client and HTTP 401. An empty string is treated as absent, but omit the field entirely.
  • A public client must send code_challenge and code_challenge_method=S256 on the authorization request, and the matching code_verifier on 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_id and refresh_token — again with no client_secret.
  • Avoid persisting the refresh token in localStorage if 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/revoke requires client_secret, and a public client presenting one is rejected with invalid_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.

Next

Was this page helpful?