[{"data":1,"prerenderedAt":527},["ShallowReactive",2],{"page-de-\u002Fgarden\u002Fwebauthn-credentials-are-domain-bound":3,"backlinks-de-\u002Fgarden\u002Fwebauthn-credentials-are-domain-bound":207},{"doc":4,"isFallback":197},{"id":5,"title":6,"body":7,"description":193,"draft":194,"extension":195,"meta":196,"navigation":197,"notice":198,"path":199,"seo":200,"stage":201,"stem":13,"tags":202,"topic":205,"__hash__":206},"garden_en\u002Fwebauthn-credentials-are-domain-bound.md","WebAuthn Credentials Are Domain-Bound",{"type":8,"value":9,"toc":183},"minimark",[10,14,23,26,31,43,46,50,55,58,61,91,99,103,109,112,131,135,150,157,161,164,173,177],[11,12,6],"h1",{"id":13},"webauthn-credentials-are-domain-bound",[15,16,17,18,22],"p",{},"A WebAuthn credential is bound to the ",[19,20,21],"strong",{},"relying-party ID"," — the domain it was registered under. That binding is part of the credential itself, not a setting alongside it.",[15,24,25],{},"Change the domain and every registered credential becomes worthless. Not degraded, not migratable: worthless. Every user re-registers their hardware.",[27,28,30],"h2",{"id":29},"why-this-is-sharper-than-it-sounds","Why this is sharper than it sounds",[15,32,33,34,38,39,42],{},"The binding is deliberate, and it's the reason passkeys resist phishing. A credential registered for ",[35,36,37],"code",{},"id.example.com"," will not be offered on ",[35,40,41],{},"id-example.com",", because the browser refuses to match them. The user cannot be talked into making that mistake, because the check happens below the layer where persuasion works.",[15,44,45],{},"The property that makes the credential unphishable is exactly the property that makes it immovable. You don't get one without the other.",[27,47,49],{"id":48},"the-part-that-catches-people","The part that catches people",[15,51,52],{},[19,53,54],{},"This decision is made at first deploy, usually without being recognised as a decision.",[15,56,57],{},"You pick a hostname for the login service because you need to put it somewhere. Nothing warns you. Everything works. The choice looks like a DNS detail, filed mentally alongside the reverse-proxy config.",[15,59,60],{},"Then it sets. And it sets harder than almost anything else in the deployment:",[62,63,64,72,78,84],"ul",{},[65,66,67,68,71],"li",{},"Change your ",[19,69,70],{},"database","? Dump and restore.",[65,73,67,74,77],{},[19,75,76],{},"hosting provider","? Move the volumes.",[65,79,67,80,83],{},[19,81,82],{},"identity provider","? Export users, import users, reconfigure the clients.",[65,85,86,87,90],{},"Change the ",[19,88,89],{},"domain your credentials were registered under","? Contact every user and ask them to re-enrol their security keys, one at a time, from a login page they can no longer log into with the thing they'd normally use.",[15,92,93,94,98],{},"That last one isn't a data migration problem. There's no data to move. It's a ",[95,96,97],"em",{},"coordination"," problem with every human who ever registered, and the fallback path is whatever recovery mechanism you set up — which is now load-bearing for your entire user base simultaneously.",[27,100,102],{"id":101},"which-means-the-hostname-is-an-architectural-commitment","Which means the hostname is an architectural commitment",[15,104,105,106],{},"Worth stating as a rule: ",[19,107,108],{},"the hostname you serve WebAuthn from is a long-term commitment, and it should be chosen with the same seriousness as a data model.",[15,110,111],{},"Practically:",[62,113,114,125,128],{},[65,115,116,117,120,121,124],{},"Pick a hostname you can keep independent of the software behind it. A domain named after the ",[95,118,119],{},"function"," survives replacing the ",[95,122,123],{},"product","; one named after the product does not.",[65,126,127],{},"Understand the relationship between the RP ID and origin before first registration, not after.",[65,129,130],{},"Treat \"we might move this later\" as a reason to choose more carefully now, not as a reason to defer.",[27,132,134],{"id":133},"seen-in-the-wild","Seen in the wild",[15,136,137,138,141,142,145,146,149],{},"This isn't theoretical caution. A concrete instance: the Kanidm deployment behind this site's services carries an explicit warning in its own module — ",[35,139,140],{},"domain"," and ",[35,143,144],{},"origin"," are pinned at first deploy to ",[35,147,148],{},"id.byteflavour.dev"," and must never change afterwards, because Kanidm bakes the domain into every WebAuthn credential it issues.",[15,151,152,153,156],{},"Notably, that warning had to be written ",[95,154,155],{},"into the deployment"," to be visible at the moment it mattered. The constraint comes from the standard, but nothing in the act of deploying surfaces it on its own.",[27,158,160],{"id":159},"the-consequence-downstream","The consequence downstream",[15,162,163],{},"This is why replacing an identity provider is much harder than its feature list suggests. Comparing two products on capabilities implies you could swap one for the other. If passkeys are registered against the current one's domain, you cannot — at least not without an event that touches every user.",[15,165,166,167,172],{},"It's a live constraint here: it's one of the two reasons the plan in ",[168,169,171],"a",{"href":170},"\u002Fgarden\u002Fquestions-to-ask-an-idp","Questions to Ask an IdP"," adds a second system rather than migrating to it. The other reason — existing services already depending on it — would eventually expire as those services were reconfigured. This one wouldn't.",[27,174,176],{"id":175},"where-this-actually-stands","Where this actually stands",[15,178,179,182],{},[19,180,181],{},"Budding."," The mechanism is standardised and settled; nothing here is expected to change. What would improve this note is the other side of it — what a domain migration actually costs when someone has to run one, how well recovery codes carry the load, and whether related-origin requests meaningfully soften any of it. None of that has been tested, so none of it is claimed.",{"title":184,"searchDepth":185,"depth":185,"links":186},"",2,[187,188,189,190,191,192],{"id":29,"depth":185,"text":30},{"id":48,"depth":185,"text":49},{"id":101,"depth":185,"text":102},{"id":133,"depth":185,"text":134},{"id":159,"depth":185,"text":160},{"id":175,"depth":185,"text":176},"The relying-party ID gets chosen at first deploy, usually without anyone noticing, and it binds harder than the database does.",false,"md",{},true,null,"\u002Fgarden\u002Fwebauthn-credentials-are-domain-bound",{"title":6,"description":193},"budding",[203,204],"security","identity","webauthn","ioI6_BamLC_R3uUEQUBOiCbzgQhfKVf0XXDW1nh1BFw",[208],{"id":209,"title":171,"body":210,"description":519,"draft":194,"extension":195,"meta":520,"navigation":197,"notice":198,"path":170,"seo":521,"stage":522,"stem":214,"tags":523,"topic":525,"__hash__":526},"garden_en\u002Fquestions-to-ask-an-idp.md",{"type":8,"value":211,"toc":506},[212,215,218,221,239,242,248,256,260,275,278,282,285,290,297,303,310,315,325,328,331,336,339,344,359,366,370,373,378,383,386,401,406,409,413,419,422,426,433,447,450,454,457,462,465,468,481,484,486,492,499],[11,213,171],{"id":214},"questions-to-ask-an-idp",[15,216,217],{},"The usual framing is a shootout: line up the self-hostable identity providers, compare features, pick a winner, deploy it. That framing quietly assumes the thing worth testing here — that one system should do the whole job.",[15,219,220],{},"The more useful move is to split the job first:",[222,223,224,229,234],"ol",{},[65,225,226],{},[19,227,228],{},"Who authenticates humans?",[65,230,231],{},[19,232,233],{},"Who issues tokens for services?",[65,235,236],{},[19,237,238],{},"Does the backend use what those tokens say?",[15,240,241],{},"The first two are different problems. The first is about authentication policy, credential strength, recovery flows, and how much you trust a password. The second is about token shapes, claims, exchange, and what a backend can prove about a request's provenance.",[15,243,244,247],{},[19,245,246],{},"They are allowed to have different answers."," Forcing both into one system means choosing a product that is inevitably stronger at one than the other, and then living with the weaker half. Once you accept two answers, each question gets to be decided on its own merits.",[15,249,250,251,255],{},"The third isn't a question about the identity provider at all — which is exactly why it gets skipped. You're shopping for an IdP, so you ask IdP questions. But an issuer that can express delegation buys nothing if the backend discards it on arrival, and that turned out to be the case here, measured rather than assumed: ",[168,252,254],{"href":253},"\u002Fgarden\u002Facting-on-a-humans-behalf","Acting on a Human's Behalf"," has the result. It doesn't change the choice below. It does thin out one of the arguments for it.",[27,257,259],{"id":258},"what-actually-forced-the-split","What actually forced the split",[15,261,262,263,266,267,270,271,274],{},"The concrete requirement was ",[168,264,265],{"href":253},"delegation"," — a service acting on a person's behalf, with both identities visible to the backend. In RFC 8693 terms: token exchange ",[95,268,269],{},"with"," an ",[35,272,273],{},"actor_token",".",[15,276,277],{},"This turns out to be an unusually good discriminator, because \"supports RFC 8693\" is a claim many products make while implementing meaningfully different subsets of it.",[27,279,281],{"id":280},"the-evidence","The evidence",[15,283,284],{},"What follows was checked against each project's own documentation. Quotes are verbatim. Where something wasn't checked, that's stated rather than glossed.",[286,287,289],"h3",{"id":288},"kanidm","Kanidm",[15,291,292,293,296],{},"RFC 8693 support landed in ",[19,294,295],{},"v1.9.0 (2026-02-17)"," — but not in the delegating form. From its service-account documentation:",[298,299,300],"blockquote",{},[15,301,302],{},"\"Service accounts can exchange their API bearer token for OAuth2\u002FOIDC tokens (access\u002Fid\u002Frefresh) without user consent or interaction.\"",[15,304,305,306,309],{},"The ",[35,307,308],{},"subject_token"," must be the service account's own API token. And explicitly:",[298,311,312],{},[15,313,314],{},"\"actor_token is not supported for this flow.\"",[15,316,317,318,324],{},"Read carefully, this is ",[95,319,320,321],{},"effect-equivalent to ",[35,322,323],{},"client_credentials",": a service obtains a token for itself. That's a genuinely useful feature. It is not delegation, and no configuration turns it into delegation, because the parameter that would express delegation isn't accepted.",[286,326,327],{"id":327},"authentik",[15,329,330],{},"Supports both forms:",[298,332,333],{},[15,334,335],{},"\"authentik supports both impersonation and delegation as defined by RFC 8693.\"",[15,337,338],{},"With a version floor:",[298,340,341],{},[15,342,343],{},"\"Delegation and on-behalf-of token exchange are available in authentik 2026.8 and later.\"",[15,345,346,347,350,351,354,355,358],{},"Two packaging facts, both checked directly: nixpkgs currently carries ",[19,348,349],{},"2026.5.6"," — ",[95,352,353],{},"below"," the release where delegation arrived — and there is ",[19,356,357],{},"no NixOS module"," for it. So adopting it means both waiting on (or driving) a package bump and writing the service definition yourself.",[15,360,361,362,365],{},"Operationally it's a Python application plus a separate worker process, with a stated minimum of ",[19,363,364],{},"2 cores and 2 GB RAM",". Its own documentation describes Compose as being \"for test setups and small-scale production\".",[286,367,369],{"id":368},"zitadel","Zitadel",[15,371,372],{},"Supports delegation, and the documentation reads as more settled — no beta qualifier in the current docs.",[298,374,375],{},[15,376,377],{},"\"currently only a valid access token or ID token are allowed as actor token\"",[298,379,380],{},[15,381,382],{},"\"The user represented by the actor token must have the impersonation permission set, or else the request will be rejected\"",[15,384,385],{},"That second line matters: delegation isn't ambient, it's a permission you grant. There are four permission levels along two axes (IAM\u002FOrg × Admin\u002FEndUser), so the authority to act on someone's behalf is itself scoped rather than binary.",[15,387,388,389,392,393,396,397,400],{},"Operationally: Go and PostgreSQL, roughly ",[19,390,391],{},"512 MB RAM"," for the service, a NixOS module at ",[35,394,395],{},"services\u002Fweb-apps\u002Fzitadel.nix",", package ",[19,398,399],{},"2.71.7",". It federates to generic OIDC providers, and is explicit about what that means for the resulting user model:",[298,402,403],{},[15,404,405],{},"\"All user profiles are managed within ZITADEL, allowing for a unified view of user identities regardless of the IdP used for authentication.\"",[15,407,408],{},"It also does passkeys natively per FIDO2\u002FWebAuthn.",[286,410,412],{"id":411},"keycloak-not-evaluated","Keycloak — not evaluated",[15,414,415,418],{},[19,416,417],{},"This was not checked."," Keycloak was excluded early on operational grounds — a JVM deployment, heavier than the rest of the estate — and its delegation capabilities were never investigated.",[15,420,421],{},"Stating that plainly matters. Keycloak is the most widely deployed option in this category and may well handle delegation perfectly. It isn't ranked here because no ranking was performed on it, and presenting an unexamined product alongside three examined ones would misrepresent how much work went into the comparison.",[27,423,425],{"id":424},"kanidms-own-axis","Kanidm's own axis",[15,427,428,429,432],{},"It would be easy to read the above as \"Kanidm fell short.\" That misreads the situation, and its own comparison documentation makes the point: it offers stronger authentication policy, ",[19,430,431],{},"WebAuthn attestation"," (which the others don't provide), Unix authentication integration, and a deliberate choice to implement its own database rather than depend on an external SQL server.",[15,434,435,436,439,440,442,443,446],{},"Those are real strengths, and they all sit on the ",[95,437,438],{},"first"," axis — authenticating humans well. None of them are weakened by the absence of ",[35,441,273],{},", because delegation is a question on the ",[95,444,445],{},"second"," axis.",[15,448,449],{},"Kanidm chose a design space on purpose. The requirement here simply grew outside it. That's a different finding from \"built badly\", and the distinction is the whole reason the two-question framing is worth adopting.",[27,451,453],{"id":452},"the-plan","The plan",[15,455,456],{},"Not yet implemented — this is the decision, not a report on living with it:",[15,458,459],{},[19,460,461],{},"Keep Kanidm as the identity source for humans. Add Zitadel alongside it for token issuance and delegation, federating to Kanidm for authentication.",[15,463,464],{},"Humans keep authenticating where they already do. Services get an issuer that can mint tokens naming two parties. Neither system is asked to do the thing it wasn't designed for.",[15,466,467],{},"Two reasons this is an addition rather than a migration:",[222,469,470,473],{},[65,471,472],{},"Real services already authenticate against Kanidm in production — the git forge and the fediverse server.",[65,474,475,478,479,274],{},[19,476,477],{},"The passkeys are domain-bound."," Moving would ask every user to re-enrol their hardware. That constraint deserves its own note: ",[168,480,6],{"href":199},[15,482,483],{},"The second reason is the one that would still apply even if the first didn't.",[27,485,176],{"id":175},[15,487,488,491],{},[19,489,490],{},"Seedling."," The decision is made; nothing is deployed. Most of the above is documentation-checking, which is exactly the kind of evidence that survives contact with reality least well — federation in particular tends to be where the interesting problems are, and none of them have been met yet.",[15,493,494,495,498],{},"One piece has since been measured rather than read: the exchange itself works end to end against the real backend, and that backend ignores the ",[35,496,497],{},"act"," claim. The plan below is unchanged by it — the reasons for it were never only about audit — but one of its arguments is thinner than it looked.",[15,500,501,502,505],{},"Two known gaps, kept visible rather than quietly dropped: ",[19,503,504],{},"Keycloak was never evaluated",", and running two identity systems has an ongoing operational cost that hasn't been paid yet and therefore can't honestly be reported on. This note gets revisited once it has.",{"title":184,"searchDepth":185,"depth":185,"links":507},[508,509,516,517,518],{"id":258,"depth":185,"text":259},{"id":280,"depth":185,"text":281,"children":510},[511,513,514,515],{"id":288,"depth":512,"text":289},3,{"id":327,"depth":512,"text":327},{"id":368,"depth":512,"text":369},{"id":411,"depth":512,"text":412},{"id":424,"depth":185,"text":425},{"id":452,"depth":185,"text":453},{"id":175,"depth":185,"text":176},"Which identity provider?",{},{"title":171,"description":519},"seedling",[203,204,524],"self-hosting","identity-provider","im6mTU-ZGplSH07m1Rrp3imlket6iGZ9rJoraVNuU_w",1789414696785]