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.
Header
Section titled “Header”| 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.
Claims
Section titled “Claims”| Claim | Required | What it must be |
|---|---|---|
iss |
yes | The issuer Doshi assigned you, such as urn:, 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-, such as 2009-. See customer_since. |
edition |
no | The key of the edition this person is in, as a string. See edition. |
sub is an id, never an email
Section titled “sub is an id, never an email”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.
customer_since
Section titled “customer_since”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.
edition
Section titled “edition”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.
Lifetimes and clock skew
Section titled “Lifetimes and clock skew”| 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.
Bringing your own key
Section titled “Bringing your own key”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,ES256orRS256. 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
kidwe made. It must be reachable from the public internet over HTTPS, and it must serveapplication/json. Add the URL first; the console shows the key’skid. Publish your key under thatkid, 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.
Rotating your keys
Section titled “Rotating your keys”- 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. - For a JWKS key, confirm the new key is actually served at its URL under that
kid, for example withcurl, then start signing with it. The unknownkidmakes us refetch, so no cache lifetime has to pass. Sign too early, though, and that refetch finds the old set: the newkidis refused withinvalid_clientfor up to 60 seconds, until we may refetch again. - 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.