Skip to content

The assertion

What your backend signs — claims, lifetimes, algorithms, and the keys we verify it against, how you bring your own, and how you rotate them.

The assertion is a JWT your backend signs to say “this is my customer, and I have already authenticated them”. It is an RFC 7523 §2.1 client assertion: short-lived, single-purpose, and never seen by a browser.

Field Value
alg ES256 or RS256. Nothing else is accepted. Must match the algorithm that key was added with.
kid Required in every assertion. The kid we gave the key that signed it.
typ JWT, if present. Optional.

kid is required rather than optional because it is the whole of your rotation story: add a new key alongside the old one, start signing with the new kid, and delete the old key when you are ready. See Rotating your keys.

Claim Required What it must be
iss yes The issuer Doshi assigned you, such as urn:doshi:<slug>:live, from your console’s Setup values. Exactly as shown: this is what we look you up by.
sub yes Your own stable customer id. The immutable handle for this person: any non-empty string, sent identically on every assertion.
aud yes The exchange endpoint’s absolute URL, the same in Live and Test: the exchange URL in your console’s Setup values, exactly.
exp yes Expiry. 60 s after iat is the recommendation; 300 s is the enforced ceiling.
iat yes Issued-at.
jti yes A fresh unique id per assertion. We log it; we do not store it.
email no Optional. We do not store or use it. The member is identified by sub alone.
name no The member’s name as Doshi shows it. Send it on every assertion; the latest one wins. Members cannot change it inside Doshi. Leave it out and the name we last received stays.
customer_since no The date this person became your customer, as YYYY-MM-DD, such as 2009-04-17. See customer_since.
edition no The key of the edition this person is in, as a string. See edition.

sub is the identity anchor: it is what maps to a coach member, and everything that person does in the coach hangs off it. It must be stable for the life of the relationship. If someone changes their email address at your bank, sub must not change — otherwise their coach history splits in two and the old half is unreachable.

Neither name nor email is ever treated as an identifier. Changing name updates what the member sees at their next sign-in or token renewal. email is ignored.

The date this person became your customer, as a calendar date: YYYY-MM-DD, such as 2009-04-17. Not a timestamp, not a number. Doshi uses it when it checks a reward redemption, so that someone who has banked with you for years is not treated as new because they opened Doshi this week. Without it, Doshi counts from the member’s first sign-in.

Send it on every assertion if you can; the latest one wins. Leave it out and the date we last received stays. A value that is not a real date in that form, or is in the future, is ignored and the sign-in goes ahead. Doshi never shows it to the member.

If you offer Doshi to different groups of your customers, such as each employer you resell to or each tier of your product, each group can be an edition with its own brand, courses and rewards. Every client has a default edition. edition names the one this person is in, by the key the edition was created with, exactly and as a string.

Send it on every assertion; the latest one wins, so a person who moves between your tiers moves in Doshi at their next sign-in or token renewal. Leave it out, or send null, and the edition we last received stays. A person we have never seen lands in default.

A key you never created rejects the exchange with invalid_grant and the reason unknown_edition, so a typo fails loudly rather than landing anyone in the wrong place. A key whose edition you archived signs the person in to default. The edition is never part of who the person is: the same sub is the same member in every edition, and keeps their progress and points when they move.

Privileged claims are rejected, not ignored

Section titled “Privileged claims are rejected, not ignored”

An assertion carrying any of these claims is rejected outright with invalid_grant. We do not strip them and carry on.

role, tenant_id, environment_id, environment_kind, client_id, aal, amr, app_metadata, is_anonymous, session_id, user_metadata

Watch for amr: an assertion built by copying claims from your own OIDC ID token often carries it. Your tenant is derived from your iss; you never assert it.

Value
Assertion lifetime, recommended 60 s
Assertion lifetime, enforced ceiling 300 s
Clock skew tolerated on exp / iat / nbf ±60 s

A 60 s assertion is not a constraint on your users — it is created at the moment your backend is asked for a token, and used immediately.

Every key you sign with is added in your console, under a kid we make for it, such as prd_7f3a91c2. Live and Test each have their own issuer, and up to five keys can be active per issuer at once.

By default the console generates the key in your browser and keeps its public half. Adding a key says when to bring your own instead. It reaches us one of two ways:

  • Its public key, pasted into your console. A PEM (BEGIN PUBLIC KEY) or a JWK, with its algorithm, ES256 or RS256. We hold it, so there is nothing for you to host.
  • A JWKS URL you name. We fetch the key from that URL, so it must publish the key under the kid we made. It must be reachable from the public internet over HTTPS, and it must serve application/json. Add the URL first; the console shows the key’s kid. Publish your key under that kid, then sign with it.

A JWKS publishes each key as a JWK with its kid, alg and "use": "sig":

{
"keys": [
{
"kty": "EC",
"crv": "P-256",
"alg": "ES256",
"use": "sig",
"kid": "prd_7f3a91c2",
"x": "...",
"y": "..."
}
]
}

We cache a JWKS according to the Cache-Control you serve it with, clamped to between 5 minutes and 1 hour, defaulting to 10 minutes if you send none. An assertion carrying a kid we have not seen triggers a refetch, at most once per 60 seconds per JWKS URL, so a new key needs no cache lifetime to pass.

  1. Add a new key in your console alongside the current one. It gets a new kid. A key the console holds verifies from that moment.
  2. For a JWKS key, confirm the new key is actually served at its URL under that kid, for example with curl, then start signing with it. The unknown kid makes us refetch, so no cache lifetime has to pass. Sign too early, though, and that refetch finds the old set: the new kid is refused with invalid_client for up to 60 seconds, until we may refetch again.
  3. Disable or delete the old key once nothing signs with it. For a JWKS key, also remove it from the JWKS at its URL.

Disabling or deleting a key stops mints for it at once. Removing a key from a JWKS without disabling it in your console stops them only once our copy expires, so for that, your Cache-Control is your revocation SLA. Neither can shorten the life of a member token already minted. The worst case for a key removed from a JWKS is one cache lifetime plus one member-token lifetime:

  • at the default 10-minute cache: ≤ 25 minutes
  • at the 1-hour clamp: ≤ 75 minutes

Serve a shorter Cache-Control if you want a tighter number. If a private key may have leaked, see Pause integration.