1. Home
  2. Blog
  3. Why we didn't add a token_delivery parameter

Why we didn't add a token_delivery parameter

A request flag with a safe default is not a safe design, because an attacker simply doesn't take the default. How refresh-token delivery moved off the request.

Mobile apps broke our refresh tokens, and the obvious fix would have broken everything else.

Canopy issues a refresh token when someone signs in. In a browser it goes into an httpOnly cookie, which is the right answer there for one specific reason: page JavaScript cannot read it. A script injected into a customer's site cannot steal what the browser refuses to hand over.

An installed phone app has none of that. No cookie jar, nothing re-sending credentials in the background. So the token has to come back in the response body, and the app stores it in the Keychain or the Keystore.

Which means the API needs to know which kind of client is asking. The obvious way to tell it is a parameter on the request: token_delivery: "cookie" | "body", defaulting to cookie. That is what our own plan said to build.

It took us longer than it should have to see why that is unshippable.

The design that looks right

Be fair to the parameter first. It is flexible. It is backward-compatible: every existing caller sends nothing and keeps its cookie. It defaults to the secure option, so a developer who never reads the docs gets the safe behaviour. Reviewed on its own, it is the conservative choice, and it is what most engineers reach for, including us.

Now break it, in one move.

A default protects nobody, because an attacker does not take the default.

Picture the attack the cookie exists to stop: a script injected into a customer's website. That script cannot read the httpOnly cookie. It does not need to. It calls /refresh, the browser attaches the cookie automatically, and the script asks for the response to be shaped differently:

What the site sends
JSON
{ "publishable_key": "pk_…" }
What the injected script sends
JSON
{ "publishable_key": "pk_…", "token_delivery": "body" }

The server rotates the token and returns the new one in JSON, to the script, which exfiltrates it. The cookie did its job perfectly and it did not matter, because the protection was conditional on the caller choosing it.

So the mode cannot live on the request. It has to live somewhere the caller cannot restate.

Delivery follows the key

In Canopy, an application signs users in with a publishable key: a pk_… value that names an Environment and grants nothing on its own. When you create one you say what will use it, Web or Native. A browser app ships a web key and gets cookies. A phone app ships a native key and gets the token in the body. The developer decides once, in the Developer Console, and no request can decide differently.

Two request shapes: on the left a request carrying token_delivery: body is handed the refresh token; on the right the mode is fixed on the publishable key, web or native, and the request has no field to change it
Left: the mode on the request, which an injected script can set. Right: the mode on the key, which it cannot.

The attack above now has nowhere to go. The injected script can send the site's web key, and gets a cookie it cannot read. It cannot promote itself to body delivery, because body delivery is not a thing a request says.

Body delivery itself was not new. A trusted backend holding the Environment's secret API key already received the token in the body at login, and still does. That path is safe for a different reason: the credential that unlocks it cannot be held by browser JavaScript. The native key is the first time a public credential leads to body delivery, which is why where it lives matters so much.

The blocking question that wasn't

We spent real effort on a question we believed the whole piece of work turned on: can a client that holds no secret be trusted with a refresh token at all? A phone app is a public client. Anything it ships, it ships to everyone.

RFC 9700 had already answered it. It is BCP 240, published in January 2025, and section 2.2.2 says that refresh tokens issued to public clients must be either sender-constrained or rotated on every use. The trust does not come from a secret. It comes from making every token single-use, so a stolen one is worth exactly one refresh, and a replay of it is evidence.

Canopy rotates on every use, for every client. We do not implement DPoP, the sender-constraining branch, and rotation alone satisfies the requirement. The question was closed before we asked it; we just had not read far enough. RFC 9700 is recent enough that plenty of teams have not internalised it yet, which is the only reason this admission is useful rather than embarrassing.

Structural beats conditional

Having decided that delivery follows the key, the next instinct is a check: on refresh, look up the key, confirm its type matches the session, refuse if not. That is correct. It is also one refactor away from being wrong, because it is a branch, and branches get reordered, bypassed and forgotten.

What we shipped instead is that /refresh accepts no publishable key at all.

HTTP
POST /v1/identity/auth/refresh
Content-Type: application/json

{ "refresh_token": "…" }

A session records how it delivers its refresh token when it is created, and keeps that for its whole life. Every rotation inherits it. On refresh there is nothing to compare, because there is nowhere to state the mode: a request that includes a publishable_key is rejected by body validation before any logic runs. A browser caller sends no body at all; the token rides in the cookie.

The two shapes are worth setting side by side, because they look similar and are not:

  • Conditional: "on refresh, check that the key's type matches the session". A defence you have to keep.
  • Structural: the parameter does not exist. A defence that cannot regress, because there is no code path for it to regress through.

If a user needs to move from one mode to the other, they sign in again. That is the only door, and it is the one the key already guards.

Rotation on networks that drop things

Single-use tokens have a cost, and mobile networks collect it.

Every refresh mints a successor and retires the token it was given. Presenting a token that has already been rotated is, by default, treated as theft: it ends every session the identity holds. That response is correct when someone really is replaying a stolen token. It is wrong when the response was lost after the server had already rotated, which on a phone moving between cells is routine. The app retries with the only token it has, and its user is signed out everywhere for a packet that never arrived.

The common answer is a fixed overlap window: accept a replay if the rotation happened within the last N seconds. It is simple, and it treats theft and a dropped response as the same thing, because a timer cannot tell them apart.

Canopy's rule looks at the chain instead. A replay is forgiven only when all three hold:

  1. the presented token was rotated within the last 5 minutes;
  2. it comes from the same software, judged by user agent; and
  3. its successor has never been used.

The third check is the one that carries the weight. If a retired token's successor was consumed, two independent parties hold tokens from the same chain. One of them is not the app that lost a response; that is theft, and it ends every session. If the successor was never used, the only party holding a live token is the one retrying, so Canopy retires the unused successor, issues a fresh one, and the chain keeps exactly one live head. The app does nothing special. It retries with the token it has.

A timer bounds how long a race can last. The chain says whether it was a race at all.

What the parameter would have cost

Nothing in this design is clever. Each step is the smaller of two options: a property of the key instead of a field on the request, no parameter instead of a check, a question answered by a spec instead of by us, and a fact about the chain instead of a guess about the clock.

The general lesson is the one we had to learn in our own plan. When a security property depends on the client choosing correctly, it is not a property. It is a suggestion, and the one caller you built it against will decline it. Move the decision to something the client cannot restate, and then remove the place where they would have restated it.

Referenced in this post Read the native apps guidecanopy-io.com