PKCE for mobile and desktop apps

PKCE (Proof Key for Code Exchange, RFC 7636) lets an app that cannot keep a secret use the code flow. A mobile or desktop app sends a one-way fingerprint of a random value when it starts the sign-in. Later it proves it holds that value when it exchanges the code. A code that someone else catches on the way is useless to them. This is also what lets a native app come back on its own scheme, such as myapp://callback.

Why use PKCE

  • No secret inside your app. A client secret shipped in an app can be read out of it. With PKCE your app proves itself with a value it makes fresh for every sign-in and never sends until the exchange.
  • Your own scheme as the callback. Any app on a device can claim a scheme like myapp://, so Hivesigner never sends a token to one. With PKCE it sends a code instead. Only your app can exchange it.
  • A stolen code is worthless. The exchange needs the verifier, which never travels through the browser.
  • The challenge is signed. Hivesigner puts the challenge inside the code that the user's key signs. Nobody can swap or remove it after the user approved.
  • Short lived and single use. A PKCE code can be exchanged once, within 10 minutes (with up to 5 more for a device clock that is off). The API keeps used codes in memory, so a restart forgets them; the verifier is still needed.
  • Existing apps keep working. A code without a challenge works exactly as before. See Existing apps.

How it works

 Your app                        Hivesigner                      Hivesigner API
 ────────                        ──────────                      ──────────────
 1. verifier  = random secret
    challenge = BASE64URL(SHA256(verifier))
    keeps the verifier in memory

 2. opens /oauth2/authorize ────► 3. user picks an account
    with code_challenge              and approves
                                    signs a code that CONTAINS
                                    the challenge

 4. myapp://callback?code=CODE ◄── redirects with the code
    (another app may see this,
     it cannot use the code)

 5. POST /api/oauth2/token ─────────────────────────────────────► 6. checks the user's signature,
    code + code_verifier                                              SHA256(verifier) == challenge,
                                                                      age under 10 minutes (+5),
                                                                      not used before

 7. access_token ◄──────────────────────────────────────────────── issues the token

The verifier is the only thing that turns the code into a token. It never leaves your app until step 5. That step goes straight to the API over https, not through the browser or the operating system.

Register your app

Set up your app account as on Register your app, with two differences:

  • Callback. Add your app's own scheme to Redirect URIs, for example myapp://callback or com.example.myapp:/callback. It has to match exactly, with no #fragment. The scheme is a lowercase plain name such as myapp or a lowercase reverse domain name such as com.example.my-app, in plain ASCII. Hivesigner accepts it only for a code request with a valid PKCE challenge. An https App Link or Universal Link, or a loopback address, works as well.
  • No client secret. An app account with no secret is treated as an app that cannot keep one. PKCE alone is enough to exchange its codes. If the account already has a secret because a server uses it too, see A server and a native app on one account.

Schemes that run code or open something other than your app are never accepted as a callback: javascript, data, vbscript, file, blob, about, intent, market, browser schemes such as googlechrome or firefox. Also any scheme with a plus, or a dash before its first dot (x-safari-https, ms-msdt, web+app).

Step by step

1. Make a verifier and its challenge

Make a new pair for every sign-in. The verifier is 43 to 128 characters of A-Z a-z 0-9 - . _ ~. The challenge is its SHA-256 hash in base64url, without = padding.

function base64url(bytes) {
  return btoa(String.fromCharCode(...bytes))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

const verifier = base64url(crypto.getRandomValues(new Uint8Array(32)));
const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(verifier));
const challenge = base64url(new Uint8Array(digest));

To test your own code, the verifier dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk gives the challenge E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM (RFC 7636, appendix B).

With the JavaScript SDK (4.1 or later), createPkcePair() does this for you. See Use the JavaScript SDK.

2. Send the user to Hivesigner

Open the authorize URL in the system browser (or an in-app browser tab), with response_type=code and the two PKCE parameters:

https://hivesigner.com/oauth2/authorize?client_id=myapp&redirect_uri=myapp%3A%2F%2Fcallback&response_type=code&scope=posting&code_challenge=CHALLENGE&code_challenge_method=S256&state=STATE
Parameter Value
response_type code. The token flow never uses a custom scheme.
code_challenge The challenge from step 1, 43 characters.
code_challenge_method S256. The plain method is not supported.
state A random value you compare on return. See Protect the request with state.

3. Receive the code

After the user approves, Hivesigner opens your callback:

myapp://callback?code=CODE&state=STATE&username=alice

Compare state first. Do not trust username: the account is in the code and in the exchange answer.

4. Exchange the code

Send the code and the verifier to the API, with no client secret:

curl -X POST https://hivesigner.com/api/oauth2/token \
  -H 'Content-Type: application/json' \
  -d '{"code": "CODE", "code_verifier": "VERIFIER"}'
{
  "access_token": "ACCESS_TOKEN",
  "expires_in": 604800,
  "username": "alice"
}

There is no refresh_token: refreshing needs the client secret, which a native app does not have. When the access token expires, send the user through the authorize URL again. A user who approved your app before sees a one tap "Sign in" screen. Forget the verifier once the exchange is done.

Then use the access token with the API as any other app does.

Use the JavaScript SDK

import { Client, createPkcePair } from 'hivesigner';

const client = new Client({
  app: 'myapp',
  callbackURL: 'myapp://callback',
  responseType: 'code',
  scope: ['posting'],
});

const { codeVerifier, codeChallenge } = await createPkcePair();
const url = client.getLoginURL(state, undefined, { codeChallenge });
// ...open url, receive myapp://callback?code=CODE...
const tokens = await client.exchangeCode(code, codeVerifier);

What Hivesigner checks

Request Result
response_type=code, an S256 challenge, a registered myapp://callback Accepted: the code carries the challenge
The same without code_challenge Refused: the callback is not registered for this request
The same with code_challenge_method=plain or no method Refused: the challenge is ignored, so the callback is too
The token flow with myapp://callback Refused: a token is never sent to a custom scheme
An https callback with a valid S256 challenge Accepted: the code carries the challenge
An https callback with plain or a malformed challenge Accepted as before: the challenge is ignored and the code needs the client secret
scope=login with response_type=code The code says scope: "login" and is exchanged for a login token, never posting access. A code request with no scope or another scope is posting, as it always was.
Exchange Result
A PKCE code with the right verifier Tokens
A wrong, missing or malformed verifier 401 invalid_grant
The same code a second time 401 invalid_grant "The code was already used"
A PKCE code older than 15 minutes (10, plus 5 for clock differences) 401 invalid_grant "The code has expired"
A PKCE code dated more than 5 minutes ahead 401 invalid_grant "The code is dated in the future"
A code whose challenge was changed 401 invalid_grant: the user's signature no longer matches
A PKCE code used as a token, for example at /api/me 401 invalid_grant
A code without a challenge, with the client secret Tokens and a refresh token, as before
A client_secret that is sent but wrong. Any value counts as sent: a number, an object, false, 0, null or an empty string, in the body or the query 401 invalid_grant, even when PKCE alone would do
No secret, for an app with an IP allowlist (allowed_ips) 401 invalid_grant: such an app always needs its secret

A wrong verifier does not use up the code: the app that holds the right one can still exchange it.

A server and a native app on one account

An app account that has a client secret is treated as a server. Its codes need the secret even when they carry a challenge. This keeps PKCE from weakening an existing app: someone who starts a sign-in with their own challenge and then gets hold of the code still cannot exchange it without the secret.

When your website (with the secret) and your mobile app (without it) share one app account, tick Let this app's native app exchange PKCE codes without the secret on the profile page. It saves "pkce_public": true in the app profile. Then:

  • A PKCE code exchanged without a secret gets an access token, with no refresh token.
  • A PKCE code exchanged with the secret also gets a refresh token.
  • A code without a challenge still needs the secret.
  • A secret that is sent must always be the right one.

Only the value true counts. Leave it off when every client of the account can keep the secret.

Turn it on only when you control every callback registered on the account. With it, whoever receives a code at one of those callbacks after starting the sign-in can exchange it without the secret. For an https or loopback callback that is no new exposure, since the token flow already sends a token there. A custom scheme can be claimed by any app on the device, so give the native app a verified App Link or Universal Link where you can. An account with an IP allowlist (allowed_ips) always needs the secret, so it cannot serve a native app.

What PKCE does not cover

PKCE stops a code that was caught on the way from being used. It does not stop another app on the same device from claiming your scheme and starting a sign-in of its own, with its own verifier, while the consent screen names your app. If the user approves, that app gets a token for your app.

  • Prefer an https callback that the system opens only in your app (Android App Links, iOS Universal Links) when you can. PKCE still protects the code there.
  • Use a scheme based on a domain you own, such as com.example.myapp:/callback, so it is less likely to collide with another app.
  • Keep scopes and trust in proportion. The consent screen names your app and shows your registered callback either way, so it cannot tell a user which app will open it.

Existing apps

Nothing changes for an app that does not send a challenge. Its codes need the client secret, have no age limit and can be exchanged more than once, as before. Its https and loopback callbacks work as before. The token flow still refuses custom schemes.

Some OAuth libraries send an S256 challenge on their own. For such an app the code now carries the challenge, so:

  • the exchange needs the verifier as well as the secret (these libraries send both);
  • the code can be exchanged once, within the time above;
  • the code is no longer accepted as a token at /api/me. Exchange it first.

A plain or malformed challenge is ignored as before.

The code format

A code is a Hivesigner token of type code. See What a token looks like. With PKCE its signed_message has two more fields, in this order. A code for a sign-in only request (scope=login) also has "scope": "login" right after app.

{
  "type": "code",
  "app": "myapp",
  "code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
  "code_challenge_method": "S256"
}

The signature covers SHA-256 of these exact bytes, with no spaces:

{"signed_message":{"type":"code","app":"myapp","code_challenge":"E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM","code_challenge_method":"S256"},"authors":["alice"],"timestamp":1800000000}

Its SHA-256 is 8de0c7bf487c57a3f4b3a616d139c4c6a4a1a61411c7cd222cd796d924c22836. The timestamp is Unix time in whole seconds: a value in milliseconds is refused as dated in the future. Another signer that issues Hivesigner codes, such as a wallet app signing in to a sister app, has to produce the same bytes, signed by one of the user's posting, active or owner keys. The API then exchanges that code like any other.

A code is not a sign-in (see Kinds of token). A service of your own that does accept your app's codes directly, without the exchange, must refuse every code whose signed_message has a code_challenge field, whatever its value. Only the exchange checks the verifier: a service that accepts the code without it lets whoever intercepted the code skip that check.