Discovery
For a domain D, wellknown.id tries both:
- HTTPS:
GET https://D/.well-known/id, returning JSON. - DNS: a TXT record at
_wellknown-id.D, with the valuewellknownid=cbor:<base64url(CBOR)>. The CBOR follows the same schema as the JSON. A record may span several TXT strings, which are joined in order.
If both give a valid config, DNS wins. Results are cached for at most an hour (a few minutes today). The DNS form is size-constrained, so it should carry only redirect_uris, and jwks, token_endpoint_auth_method and self_issued where they're used; publish policies over HTTPS.
Serve the file to any origin (Access-Control-Allow-Origin: *): it's public, and a holder's wallet in a browser reads your terms from it.
Schema (version 1)
{
"version": 1,
"relying_parties": [
{
"redirect_uris": ["https://acme.example/callback"],
"jwks": { "keys": [{ "kty": "OKP", "crv": "Ed25519", "x": "…", "kid": "2026-09" }] },
"token_endpoint_auth_method": "private_key_jwt",
"client_name": "Acme",
"logo_uri": "https://acme.example/logo.svg",
"policy": "deny not_p256 if principal.key_type != \"P-256\";",
"self_issued": "forbid"
}
],
"policy": "…karu source applying to every entry on this domain…",
"privacy": {
"controller": "Acme Ltd",
"notice": { "url": "https://acme.example/privacy", "version": "3", "sha256": "…64 hex digits…" },
"collects": ["age_equal_or_over"],
"purposes": ["age checks at checkout"],
"retention": "P30D",
"erasure_endpoint": "https://acme.example/wellknown/erasure"
},
"delegation": {
"accept": true,
"hpke_public_key": { "kty": "OKP", "crv": "X25519", "x": "…" },
"mailbox": "https://acme.example/mailbox",
"scopes": [{ "name": "read-statements", "description": "Read your statements" }],
"max_days": 90
}
}
| Field | Required | |
|---|---|---|
version | yes | 1. Unknown versions are refused. |
relying_parties[].redirect_uris | yes | Absolute https URIs on this domain or its subdomains, matched exactly. A URI may appear in only one entry. |
relying_parties[].jwks | no | Your public keys, { "keys": [ … ] }: Ed25519 (OKP) or P-256 (EC). A key with private members (d and the rest) is refused. |
relying_parties[].token_endpoint_auth_method | no | none (the default: a public client, with PKCE) or private_key_jwt, which needs jwks. |
client_name, logo_uri | no | Shown on the sign-in pages. |
relying_parties[].self_issued | no | allow or forbid (the default): whether this entry takes self-issued tokens straight from the person's page, verified by your site (the web SDK). A request for one that the entry forbids is refused before the person is asked anything. |
relying_parties[].policy, policy | no | karu source (policies). The top-level one applies to every entry and an entry's to that entry; both apply. Self-contained (no import), at most 32 KiB. One that doesn't compile refuses the client. |
privacy | no, for now | What you collect and why: controller, notice (url, version, sha256 as 64 lowercase hex digits), collects (claim names), purposes, retention (an ISO 8601 duration such as P30D, or words) and erasure_endpoint (HTTPS), where holders' signed erasure requests are posted. Wallets fetch it only when the holder acts (withdrawing consent, asking for erasure), never in the background. |
delegation | no | Whether you accept sign-ins by someone acting for a holder: accept, hpke_public_key (the X25519 key grants are sealed to), mailbox (the HTTPS URL of the mailbox you read grants from; wellknown.id's if absent), scopes (what may be delegated, each a lower-case name and a description shown to the holder) and max_days (the longest a grant may run: 365 at most, and by default). Delegation has the whole of it. |
mfa_providers is reserved for MFA orchestration later.
Your client_id
Your client_id is your domain: a bare, lowercase hostname, internationalised names in punycode, with no port, path or trailing dot. https://Acme.example/ and acme.example mean the same. The types as wellknown.id reads them are in the API reference.