Troubleshooting
Almost every failure in this flow is silent by design on the wire. A refused app description reaches your user as one generic refusal naming your domain, whichever way it actually failed; a gated request comes back as a bare status code. That is deliberate — a detailed error would be a probing oracle — and it means the diagnosis has to happen on your side, locally, with the tools below.
Start with the symptom.
| What you see | Go to |
|---|---|
| The consent screen refuses your app | The consent screen refuses your app |
| The refusal says you are not permitted | A refusal that names permission |
| Returning users get the full consent screen again | A returning user saw the full screen again |
| Sign-in works, but you get back no credential | Sign-in works but no credential arrives |
| A local client's credential request is refused | A key-proven loopback request is refused |
An API call returns 401 | 401 — the request proof was refused |
An API call returns 403 | 403 — the grant was refused |
An API call returns 503 | 503 — verification could not complete |
verify-binding says stale, broken, or no-claim | Origin-binding verdicts |
| You are not sure the server holds the right key | Is the key the server holds the key the chain published? |
The consent screen refuses your app
Every app-description failure reaches your user as the same refusal, naming your
domain and saying it does not serve a valid dfos-app.json. Check, in this
order:
- Does the document fetch?
https, no redirect, under 256KB, inside a five-second budget. A redirecting well-known is a refusal, not a hop to follow. - Is your exact redirect target in
redirect_uris? The allowlist is exact-match, trailing slash included. Preview-deploy hostnames are not in it, by design. - Does the carried chain derive
client_did? A chain that folds into a different identity is refused, as is a chain present with noclient_didat all. - Is
client_didpresent at all? Optional forscope=identityalone; required the moment your set contains any credential scope. - Is every member recognized? The document is strict — an unknown member is a refusal, not a forward-compatible extension.
The canonical list of what makes a fetch fail, with the reasoning behind each rule, is The dfos-app.json document § When a fetch fails. The same checks in recipe order are Set up § When it does not work.
Check it from outside your own network. Open your origin in the explorer at
explore.dfos.com, under
#/domain/yourdomain.example — the demo's is
explore.dfos.com/#/domain/dfos-siwd-demo.vercel.app.
It fetches the document, verifies that the carried chain derives the declared
client_did, and compares it against what the relay holds. It sees what DFOS
sees, and it recomputes all of it in your browser.
An app is never refused for being unknown to DFOS. Being unknown is the ordinary state, and there is no list whose absence counts against you — which tier you resolve on changes what the consent screen says and nothing else: same scopes, same credentials, same API access. If the screen refused you with the generic message, the document did not resolve. See Two tiers, and the tier is only about trust.
A refusal that names permission, not your document
Two refusals at the consent screen are not about your document at all, and they say so in their own words rather than the generic one above:
<yourdomain> is not permitted to use Sign In With DFOS
this client identity is not permitted to use Sign In With DFOS
The first names a domain, the second a client DID. Either is a deliberate
platform decision about that domain or that identity, applied before your
/.well-known/dfos-app.json is fetched at all — so there is nothing in the
document to fix. Re-deploying it, re-patching the chain, or minting a fresh
identity will not move it, and any credential already issued to the refused
party stops working too, so existing grants begin returning 403.
The wording is deliberately distinguishable from a failed fetch, because the two want opposite responses: one is yours to debug, and this one is not. If you believe you are seeing it in error, take it up with DFOS rather than iterating on the document.
A returning user saw the full screen again
Nothing is broken. A user who ticked "Trust this app" gets a short card on return, and there are exactly three reasons the long one comes back:
- You widened your scope set. A request asking for more than was pinned re-consents. It does not void the trust — approving with trust re-pins the union, and the next sign-in fast-paths again. A narrower ask still fast-paths.
- Your app's identity changed. A different
client_didanswering for a domain the user already trusted voids the pin, and the card leads with the diff. Rotating a key inside the same chain keeps the same DID and is invisible here; publishing a new identity is not. Serving noclient_didwhere you used to serve one counts as a change too. - The user removed the trust, or disconnected the app, from Settings → Connected apps.
There is nothing to request, retry, or configure on your side: trust is the user's decision, is never carried in your request, and cannot be asked for by an app. It also grants nothing — every check runs identically either way — so a sign-in that succeeds through the short card and one that succeeds through the long card deliver the same artifacts.
Sign-in works but no credential arrives
The signed challenge comes back as jws and did query parameters. The
credential comes back in the URL fragment, under the key credential.
Browsers never send a fragment to a server, so if your callback handler only
reads the query string it will never see one.
Three things produce an empty-handed callback:
- You asked for
scope=identity. That scope issues nothing, on purpose. Ask for a credential scope — see Ask for the scopes you need. - Your redirect is a loopback host and your request proved no client.
localhost,127.0.0.1, and::1resolve on the loopback tier, and the anonymous shape of it — noclient_did— is limited toscope=identity, with a set pairingidentityand a credential scope refused outright rather than quietly narrowed. A local client that wants a credential asks under the key-proven shape instead: carryclient_did, its identity chain, and an ask proof. See Local applications. - You are reading the fragment server-side. It never gets there. The browser has to parse it and hand it over — the shape is in step 7.
If the user denied, the browser returns to your redirect with
?error=access_denied and nothing signed.
A key-proven loopback request is refused
A local client asking for a credential is refused the same way any other app is: one generic refusal at the consent screen, with which check failed deliberately not distinguished on the wire. Three things account for nearly all of them, and all three are checkable locally before you ever open a browser:
- The ask proof is missing, or carries the wrong
typ. It must bedid:dfos:siwd-ask— not thedid:dfos:siwda user's sign-in signature carries — and the verifier requires that type exactly (why the two are scoped apart). Check that you signed with the ask helper, not the sign-in one. - The carried chain does not derive the named
client_did. It is checked by the same rules a well-known carriage is. A chain from a different identity, or aclient_didwith no chain behind it, is refused rather than resolved some other way. - The chain is over the carriage cap. 100 operations, the same ceiling a
dfos-app.jsoncarriage has. A per-install client identity should be a handful of operations; a chain that long usually means you carried a user identity by mistake.
Two adjacent checks worth ruling out: the ask proof must be signed by a key that is current in that chain's auth set — a rotated-out key signs perfectly well and verifies against nothing — and the proof covers the same canonical challenge bytes the request carries, not a re-serialization of them.
The tier in full, including what it does and does not prove, is Local applications.
When a gated request is refused
Three outcomes, and they mean genuinely different things. The full model, with the byte-level reasoning, is Conventions § When a gated request is refused. The short version, and what to actually do:
401 — the request proof was refused
The key layer failed. Your proof was missing, malformed, stale, or it did not
verify. The response carries WWW-Authenticate: DFOS.
This is almost never the credential's fault, and re-issuing a grant will not fix it. Work down this list:
- Clock skew. A proof is good for about a minute, with about a minute of allowance either side. A badly skewed server clock refuses every proof it signs. Check the host's time sync first — it is the cheapest thing to rule out.
- Signing input drift. The proof covers this exact method, host, path
(including the query string, byte for byte), and a hash of the body. Sign the
request your client actually built, not a description of it. Building the
input by hand is the usual cause;
@metalabel/dfos-client/api-authexists so you do not have to. - The key is not current for your app. This is the big one, and it has its own section below.
403 — the grant was refused
The proof was fine and the grant was not. The credential is expired, revoked, not one DFOS issued, or its action set does not cover this action on this host.
Retrying the same request will never succeed. Treat 403 as a normal, expected
state rather than an error to retry through — it is how a user withdrawing
access reaches your app. Drop the credential, delete what the grant let you read,
and put the user back in front of a sign-in button if they want to reconnect.
One case is not a 403: a credential missing only one of read:profile /
read:email still opens GET /v1/profile, and the
fields that scope covers are simply absent from the response. Check for the
key, not for a falsy value.
A 403 on every credential at once, rather than on one user's, is a
different shape of problem: check whether your app is still reaching the consent
screen at all, since a domain or client identity that is
no longer permitted loses
its issued grants along with its ability to ask for new ones.
503 — verification could not complete
DFOS could not determine whether the credential had been revoked, and declined to guess. It is not a judgment about your credential. Retry with backoff.
Is the key the server holds the key the chain published?
A 401 that survives the clock and signing-input checks is almost always this:
DFOS_APP_PRIVATE_KEY does not belong to the key DFOS_APP_KID names. It is the
one misconfiguration with no local symptom — everything boots, everything signs,
and the only evidence is a 401 arriving several steps later.
The diagnosis is to derive the public key back from the secret your server
actually loaded and compare it against what the chain says. The reference demo
does this at /api/config, which reports the public Multikey it derived from
whatever DFOS_APP_PRIVATE_KEY is set to; diff that against
dfos identity keys my-app
and a mismatch is visible immediately, before anything is clicked. Build the same affordance into your own deployment — a boot-time or admin-only endpoint that prints the derived public half. It is the single best debugging affordance in the stack, and it costs three lines.
While you are there, check the two adjacent facts:
DFOS_APP_KIDis a full DID URL —did:dfos:<id>#key_<id>, not a bare DID and not a bare key id.- Its DID half equals the
client_didin your app description. The platform issues the credential to the DID it resolves from that file, and the API checks that the proof's signer is that same DID. If they differ, every proof is signed by a stranger. - The secret is 43 base64url characters, not the 64 hex characters the seed file holds. The conversion, and the rest of the deployment recipe, is in Key custody.
If the key is right and the 401 persists, confirm the key is current in the
chain's auth set — a rotated-out key still signs perfectly well and verifies
against nothing. dfos identity keys my-app lists every key and whether its
private half is available locally.
Origin-binding verdicts
dfos identity verify-binding folds the chain claim and the domain attestation
into one verdict, mapped to an exit code so a script can branch without parsing
output:
| Verdict | Exit | What it means | What to do |
|---|---|---|---|
bound | 0 | A method attests this DID and none contradicts it | Nothing. |
broken | 1 | A method answered a different DID | Fix the publication. Something is answering for a DID that is not you. |
stale | 2 | A claim exists and every method is silent | Check hosting, TLS, and DNS. Nobody contradicted you. |
no-claim | 0 | The chain claims no domain | Run bind-domain if you meant to bind one. |
stale is not broken — silence is not contradiction, which is why the
verdict set has four members rather than two. Do not treat a hosting blip as a
hijack; see
Origin binding § stale is not broken.
Common causes, by verdict:
stale— thedfos-didfile is not deployed to production, is served on a path your host rewrites, returns a404, or the_dfosTXT record has not propagated yet. Also: a lapsed certificate, since the HTTPS method needs a valid one.broken— you bound a domain that already attests a different identity, a copied-and-pasteddfos-didfile from another deployment is still in place, or DNS carries more than onedid=record under_dfos, which is a contradiction rather than a list.no-claim—bind-domainran on a machine without a controller key and failed, or ran against a different identity than the one you are verifying.
Exit 1 is also the CLI's generic error status, so an unresolvable target or a
chain you do not hold locally exits 1 too — those print to stderr instead of a
verdict. The full model, both publication methods, and the app-description
fallback are in Origin binding.
A binding is not a gate. An unbound or stale app signs in exactly the same
way; the binding is a standing, independently checkable fact about who controls
the domain, not a precondition for anything.
Check it visually
explore.dfos.com is a browser-based verifier with no backend — it resolves from a relay and recomputes every check in your own tab, so it is a second implementation rather than a second opinion from us. Two lookups matter here:
#/domain/<yourhost>— fetches your app description, checks the carried chain derivesclient_did, and compares it against the relay.#/did/<your client_did>— renders your identity's chain and its domain attestation verdict, one row per method.
They answer different questions and can disagree without either being wrong. See Origin binding.
Next
- Set up Sign in with DFOS — the recipe these failures come from, with a check-your-work step at each stage.
- Key custody — where keys live, and the deployment
recipe behind most
401s. - Local applications — the loopback tier in full, for a CLI, an agent, or a desktop app.
- Origin binding — the verdicts in full.
- Glossary — request proof, credential, attenuation, and the rest.