---
title: PKCE and public clients
description: 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.

> **The drop-in widget already does all of this**
>
> If you are adding a login button to a web page, [the widget](/developers/oauth/widget) generates the
> verifier, derives the challenge, runs the popup and performs the exchange for you — one script
> tag, no code. This page is for hand-rolled integrations: mobile apps, native apps, and SPAs that
> need to own the flow.

## 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](https://insy.io/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

1. **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.

2. **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.

3. **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.

4. **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.

5. **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.

> **Why the code alone is not enough**
>
> An attacker who intercepts the authorization code — from a redirect URL in a log, from a
> malicious app registered on the same custom scheme — still cannot exchange it. The verifier never
> travels over the authorization request; only its hash does.

## 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`.)

```js title="pkce.js"
/** 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

```js title="start-login.js"
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

```js title="callback.js"
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();
}
```

```json title="201 Created"
{
  "access_token": "a1b7c3d9e5f20486b3c1d7e9f5a20486b3c1d7e9",
  "refresh_token": "5e2c9a71d84f0b36c7a1e93f5b820d4ec6a37f18",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "memberships.read"
}
```

> **201, and no HTTP Basic**
>
> Both quirks apply to public clients too. The token endpoint returns **HTTP 201** on success, and
> credentials are read from the body only — `client_secret_basic` is not supported. A public client
> has nothing to put in an `Authorization` header anyway, so if your library insists on one, replace
> its token request with a plain `fetch`.

## 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

<Accordion>
  <AccordionItem title="invalid_client on the token request" description="HTTP 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.
  </AccordionItem>
  <AccordionItem title="invalid_grant on the token request" description="HTTP 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.
  </AccordionItem>
  <AccordionItem title="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.
  </AccordionItem>
  <AccordionItem title="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.
  </AccordionItem>
</Accordion>

## Next

<CardGroup cols={2}>
  <Card title="The login widget" href="/developers/oauth/widget" icon="plug">
    One script tag, PKCE handled for you, results on a DOM event.
  </Card>
  <Card title="Endpoint reference" href="/developers/oauth/reference" icon="list">
    Every parameter, response and error code in one place.
  </Card>
</CardGroup>
