INTEGRATION · OPENID CONNECT
Ask for the claims you need.
MIMI is an OpenID Provider. If you have integrated “Sign in with Google”, you have already done this: discovery, an authorization code with PKCE, then a token and a set of claims. The identity work behind those claims — capture, liveness, the register, and the Data Protection Act duty on the evidence — stays with us.
This page assumes you know OIDC. It does not re-teach the spec — it states what MIMI requires, what it returns, and what it does not do.
01 · QUICKSTART
Discovery and the round-trip
Ordinary authorization code flow with PKCE. The only MIMI-specific part is the standard claims request parameter, which is how you name what you need.
The issuer
Everything starts from one URL. Fetch the discovery document at boot and take every endpoint from it — the paths below are stable, but a client that reads them from discovery survives a change that a client with them hardcoded does not.
https://mimi.ke/.well-known/openid-configuration
The document carries issuer, authorization_endpoint, token_endpoint, userinfo_endpoint, jwks_uri and end_session_endpoint. Its scopes_supported is exactly openid email profile — there is no fourth scope to discover.
The endpoints
| Path | Endpoint | Notes |
|---|---|---|
/.well-known/openid-configuration | Discovery | Every other endpoint comes from here. Read it at startup; never hardcode the rest. |
/authorize | Authorization | response_type=code only. PKCE is required of every client, public or confidential. |
/token | Token | client_secret_basic. Returns an id_token and an access token; no refresh token is issued. |
/userinfo | UserInfo | Bearer access token. Answers from MIMI's claim table — never a passthrough of anything upstream. |
/jwks | JWKS | MIMI's public signing keys. Fetch and cache; the private half never leaves the host. |
/session/end | End session | RP-initiated logout. See the note under Client registration about post-logout redirects. |
What MIMI requires of you
| Requirement | Detail |
|---|---|
response_type=code | Authorization code only. No implicit, no hybrid. |
| PKCE | Mandatory, for every client, confidential ones included. Send code_challenge with code_challenge_method=S256, and the matching code_verifier at the token endpoint. A request without a challenge is rejected with invalid_request and the description “Authorization Server policy requires PKCE to be used for this request”. |
| Client authentication | client_secret_basic at /token — the secret in the HTTP Basic header, not the body. |
| Redirect URI | Exact string match against what is registered. No wildcards, no prefixes, no trailing-slash forgiveness. |
| Scopes | openid, email, profile. Anything else is rejected with invalid_scope, and one unknown scope fails the whole request. |
| Token lifetimes | Authorization code 60s (single use). ID token and access token 10 minutes. No refresh token is issued — see Registration. |
What the token endpoint returns
A successful exchange returns access_token, token_type, expires_in, scope and id_token. There is no refresh_token, deliberately — see Registration.
The id token is RS256 with a kid naming the key to verify it against, and carries iss, aud, sub and the nonce you sent. Verify all four plus the signature against /jwks, with a library. An id token that agrees with /userinfo is not thereby validated.
Naming the claims you need
The standard claims request parameter is enabled. It is the request MIMI is built around: you name what you need, MIMI works out what is already held and prompts only for what is missing or expired.
GET /authorize
?client_id=<your-client-id>
&response_type=code
&redirect_uri=https%3A%2F%2Fbank.example%2Fauth%2Fmimi%2Fcallback
&scope=openid%20email%20profile
&state=<random>
&nonce=<random>
&code_challenge=<S256 of your verifier>
&code_challenge_method=S256
&claims=%7B%22userinfo%22%3A%7B%22email%22%3A%7B%22essential%22%3Atrue%7D%7D%7D
# the claims parameter, decoded:
{ "userinfo": { "email": { "essential": true } } }A complete round-trip
Runnable as-is on Node 20 or later with no dependencies. Register http://localhost:8787/callback as a redirect URI first, and pass your client id and secret through the environment.
// mimi-quickstart.mjs — Node 20+, no dependencies. Run: node mimi-quickstart.mjs
// Placeholders only. Never commit a client secret; read it from the environment.
import crypto from "node:crypto"
import http from "node:http"
const ISSUER = process.env.MIMI_ISSUER ?? "https://mimi.ke"
const CLIENT_ID = process.env.CLIENT_ID ?? "<your-client-id>"
const CLIENT_SECRET = process.env.CLIENT_SECRET ?? "<your-client-secret>"
const REDIRECT_URI = "http://localhost:8787/callback" // must be registered EXACTLY
// 1. Discovery. Read every endpoint from here — never hardcode them.
const conf = await fetch(`${ISSUER}/.well-known/openid-configuration`).then((r) => r.json())
// 2. PKCE + anti-forgery values. MIMI requires PKCE of every client.
const b64url = (b) => b.toString("base64url")
const verifier = b64url(crypto.randomBytes(32))
const challenge = b64url(crypto.createHash("sha256").update(verifier).digest())
const state = b64url(crypto.randomBytes(16))
const nonce = b64url(crypto.randomBytes(16))
const authUrl = new URL(conf.authorization_endpoint)
authUrl.search = new URLSearchParams({
client_id: CLIENT_ID,
response_type: "code",
redirect_uri: REDIRECT_URI,
scope: "openid email profile",
state,
nonce,
code_challenge: challenge,
code_challenge_method: "S256",
}).toString()
console.log("\nOpen this in a browser:\n" + authUrl + "\n")
// 3. Catch the redirect back, exchange the code, read the claims.
http
.createServer(async (req, res) => {
const url = new URL(req.url, REDIRECT_URI)
if (url.pathname !== "/callback") return res.writeHead(404).end()
const err = url.searchParams.get("error")
if (err) {
// Errors MIMI can redirect arrive here with the state you sent.
console.error("authorization failed:", err, url.searchParams.get("error_description") ?? "")
res.end("Failed — see the console.")
return process.exit(1)
}
// Reject a response that is not the one you started. Non-negotiable.
if (url.searchParams.get("state") !== state) {
res.end("state mismatch")
return process.exit(1)
}
const basic = Buffer.from(
`${encodeURIComponent(CLIENT_ID)}:${encodeURIComponent(CLIENT_SECRET)}`,
).toString("base64")
const tokens = await fetch(conf.token_endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
Authorization: `Basic ${basic}`, // client_secret_basic
},
body: new URLSearchParams({
grant_type: "authorization_code",
code: url.searchParams.get("code"),
redirect_uri: REDIRECT_URI,
code_verifier: verifier, // the other half of the PKCE pair
}),
}).then((r) => r.json())
// In production, VALIDATE the id_token — signature against ${ISSUER}/jwks,
// then iss / aud / exp / nonce. Use a library (openid-client, jose); do not
// hand-roll it, and do not trust an unvalidated token because /userinfo
// agreed with it.
const claims = await fetch(conf.userinfo_endpoint, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
}).then((r) => r.json())
console.log("\nclaims:", claims)
res.end("Signed in. You can close this tab.")
process.exit(0)
})
.listen(8787)
02 · CLAIMS
What comes back
MIMI answers from its own claim table. It never passes through a token or an attribute from anything behind it, and it never hands you evidence — the ID photographs and the face capture stay with MIMI, which is most of what you are buying.
The three standard claims
| Claim | Scope | Where it comes from | Lapses |
|---|---|---|---|
sub | openid | The subject identifier, stable and permanent for a person across every MIMI client. Key your account records on it. | Never |
email | email | A what-i-have claim, written at login with ceremony assurance. | When the person removes it |
name | profile | A who-i-am claim, as it reads on the document MIMI verified. | When the person removes it |
GET /userinfo Authorization: Bearer <access token>
200 OK
{
"sub": "a3f1c8b2-4d90-4a11-9f3e-0b2c7d5e9c02",
"email": "amina@example.com",
"name": "Amina W. Kariuki"
}The claim model
Every fact MIMI holds is one row: the attribute, who verified it, how, when, and when it lapses. The families are how a person reads their own store; they are not three subsystems, and a claim is the same kind of thing in each.
These are the names inside the store, not the wire format. Held is not the same as released: a passkey is a claim like any other and it is what a returning person signs in with, but no client ever receives it. You get what you asked for and the person released, and nothing else.
| Family | What it answers | Claims |
|---|---|---|
who-i-am | The person is who they say they are | name, national_id, liveness, face_match |
what-i-have | The person answering now is the one who set this up | email, passkey, work_email |
what-i-own | The person holds a thing | A signing certificate, an active subscription |
Assurance: two words, not a scale
MIMI records one of two values against every claim. This is not a numeric ladder and there is no level 1 through 4 — the question is only whether an authenticating party reported a ceremony.
| channel | ceremony | |
|---|---|---|
| What it is | An email link or an SMS code — a secret sent to an address and returned | A passkey, a brokered sign-in, or a live face match |
| What it proves | Somebody can read that inbox right now | This person is the one who set the account up |
| Relayable / phishable | Yes — it is a bearer token | No |
| Opens the store | No | Yes — this is the floor |
What this means for you: a channel proof resolves which account is meant, and never admits anyone to it. Every authorization you receive from MIMI was opened by a ceremony proof.
The ke_mimi trust framework
ke_mimi is the named trust framework MIMI issues under — a framework identifier that travels inside a claim payload, not branding, which is why it is lowercase where the product name is not. It names the rules a Kenyan verified claim was produced under: which documents count, which checks were run, and by whom.
Verified attributes
Verified attributes travel as OIDC4IDA verified_claims — the published OpenID standard for “attribute, plus who verified it, how, and when”. Nothing bespoke, and nothing for your vendor to learn beyond the spec.
GET /userinfo Authorization: Bearer <access token>
200 OK
{
"sub": "a3f1c8b2-…-9c02",
"verified_claims": {
"verification": {
"trust_framework": "ke_mimi",
"time": "2026-08-14T09:12:00Z",
"evidence": [
{ "type": "document", "document_type": "ke_national_id" },
{ "type": "biometric", "method": "face_match" }
]
},
"claims": {
"given_name": "Amina",
"family_name": "Kariuki",
"birthdate": "1994-03-08"
}
}
}Expiry, revocation, and why you must not cache
MIMI re-reads every claim on every request and derives state at read time. It holds no completion flag and no progress field, which is what lets an abandoned verification cost nothing and a late register reply upgrade a person silently. Two consequences are yours to handle:
| Behaviour | What you must do |
|---|---|
| A claim can appear between two of your requests | Re-request rather than assuming a person is stuck at the assurance you first saw. |
| A person can revoke your access at any time | Handle a later authorization returning less than an earlier one. It is not an error and not a downgrade of the person. |
| Claims expire on their own clock | Expiry is evaluated when a claim is read, never stored. Policy is per family — a liveness check lapses in 90 days, and a work address has a shorter life than a personal one. |



04 · REGISTRATION & ERRORS
Getting a client, and reading a failure
Client registration is not open. There is no self-service console and no dynamic registration endpoint — a client is configured by hand, by us, on the server.
How you get credentials
Email mail@tenda.world with your redirect URIs and the claims you need. We issue a client id and secret out of band. You will never be asked to send a secret to us, and we will never put one in an email you did not initiate.
Mechanically a client is three environment variables on the MIMI server — MIMI_CLIENT_<NAME>_ID, _SECRET and _REDIRECTS — found by scanning the environment at boot, so adding one is configuration rather than a code change. What that means for you: turnaround is a redeploy on our side, not a self-service form. Send every redirect URI you will need, for every environment, in one go.
What we configure, and what you get
| Setting | Value |
|---|---|
| Grant types | authorization_code |
| Response types | code |
| Token endpoint auth | client_secret_basic |
| PKCE | Required. Not negotiable per client. |
| Redirect URIs | A list, matched exactly. Give us every environment you need up front — adding one is a server change, not a form. |
| Refresh tokens | Not issued. A MIMI refresh token would outlive the upstream session it rests on. Re-authorize instead; a person with a live MIMI session will not see a prompt. |
| Post-logout redirects | Registered alongside your redirect URIs. /session/end ends the MIMI session and returns the person to the URI you registered. Send us the ones you need in the same message. |
Redirect URI rules
| Rule | Why |
|---|---|
| Exact string match | No wildcard, no prefix, no port fuzzing. A registered URI is compared character for character. |
| HTTPS in production | Plain HTTP is for localhost during development only. |
| No fragment | A fragment cannot survive the redirect; a URI carrying one will not match. |
| Query strings are part of the string | If your callback carries a fixed query parameter, register the URI including it. |
| One per environment | Staging and production are different URIs. Register both rather than sharing one client across them. |
Errors
Errors MIMI can safely redirect arrive at your callback as error and error_description, carrying the state you sent. Errors it cannot — anything wrong with the client id or the redirect URI itself — are rendered on MIMI’s own page instead, because redirecting them would mean trusting the thing that is wrong.
| Error | What happened | What to do |
|---|---|---|
temporarily_unavailable | A 503 from any endpoint: MIMI is not answering right now. Nothing is wrong with your request. | Treat it as the Unavailable button state — hide MIMI and fall through to your own form. Never show it as a customer failure. |
invalid_client | The client id is not registered, or the secret sent to /token is wrong. An unknown client id at /authorize renders MIMI’s own error page and does not redirect — a redirect URI belonging to an unknown client is not one MIMI will send a browser to. | If you are debugging a typo you will see a page, not a callback. Check the id against what we issued, and that the secret is in the Basic header rather than the body. |
invalid_redirect_uri | The redirect_uri is not an exact match for a registered one. Shown on MIMI’s page, never redirected. | Compare character for character, trailing slash included. Send us the URI to register if it is new. |
invalid_request | A malformed request — most often a missing code_challenge, because PKCE is required of every client. | Read error_description; it names the parameter. |
invalid_scope | A scope outside openid email profile. One unknown scope fails the whole request. | Send only the three. Use the claims parameter to ask for attributes, not extra scopes. |
unauthorized_client | A grant type the client is not configured for — a refresh token exchange, for instance. | Only authorization_code is configured. Re-authorize rather than refreshing. |
invalid_grant | At /token: the code expired (they live 60 seconds), was already used, or the code_verifier does not match the challenge. | Exchange immediately and once. Keep the verifier with the session that started the request. |
access_denied | The person declined, or did not complete what was needed. You receive nothing at all — not even confirmation that they have a MIMI. | Offer your own path. This is a legitimate choice, not a failure. |
login_required | You sent prompt=none and there was no live session to reuse. | Retry without prompt=none. |
What MIMI never sends you
| Never | Why |
|---|---|
| Any token from the provider behind MIMI | It would let you act as that person elsewhere. Reads that need it are proxied server-side, inside MIMI. |
| Evidence — ID images, face captures, biometric templates | They stay encrypted with MIMI, which is the point: the Data Protection Act 2019 duty on that material stays ours. |
| An unlogged read | Every read of a person’s claims is written to an access log, including our own. They can see it. |



