Troubleshooting
Most failures in this flow are silent on the wire: a refused app description reaches your user as one generic refusal, and a gated request comes back as a bare status code. Diagnose 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 |
| The refusal says your chain is older or diverges | A refusal that says your chain is older or diverges |
| 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 |
A write returns 409, 415, or 400 | A write is refused before the route runs |
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 document failure reaches your user as the same refusal, naming your
domain and saying it does not serve a valid dfos-app.json. Work down
The dfos-app.json document § When a fetch fails
in order; that list is the canonical one.
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. 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 says your chain is older or diverges
DFOS already holds a chain for your client_did, and the one you served or
carried does not extend it. The refusal says the chain is older than, or
diverges from, the one already held.
- Older — something is serving a copy from before your latest operation: an
old build, a preview deploy, or an app description you did not re-patch after
a rotation or a
bind-domain. Re-patch on the controller-key machine and redeploy everywhere the document is served. - Diverges — your chain and the one DFOS holds share a start but not a
history, usually because operations were signed from a stale local copy.
dfos identity status my-appshows whether your copy is behind, ahead, or diverged; serve the history DFOS already holds plus what follows it.
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. The full screen comes back when you widen your scope set or
the spaces you reach, send no spaces, change your app's identity, or the user
removed trust or disconnected the app. The rules are in
Trust, and the second sign-in. Trust
is the user's decision and grants nothing, so there is nothing to request or
configure on your side.
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 its anonymous shape — noclient_did— is limited toscope=identity. A local client that wants a credential uses the key-proven shape instead. 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. Four things account for nearly all of them, and all four 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 (what the request carries). Check that you signed with the ask helper, not the sign-in one. - The chain is missing or does not derive the named
client_did. Aclient_didDFOS has never held, sent with noclient_chain, is refused, and so is a chain from a different identity. It is checked by the same rules a well-known carriage is. - The carried chain is over a limit. Both carriage limits are in Local applications. A chain that long usually means you carried a user identity by mistake.
- The chain is older than, or diverges from, the one DFOS holds. See A refusal that says your chain is older or diverges.
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
Four 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 only within a short window (Conventions). 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.
- DFOS has never resolved the identity you signed as. A DID this platform
has never seen is a
401— or a403when it is the identity behind your credential — and never a503. It is a settled answer, so retrying will not change it. Complete one authorize flow with that identity first: a DID you minted yourself is unknown here until you do.
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.
Two refusals land here that a read-only integration never met:
- The grant does not cover this space. A space-level scope reaches only the
spaces the consent screen named. On a read the request degrades to the
anonymous view; on a write there is no anonymous view to degrade to, so it is
a
403. What your credential actually covers is inGET /v1/credential. - The content is not the granting user's.
write:postsandwrite:commentsreach that user's own posts and comments and nothing else — editing or deleting another member's content is a403whatever their role in the space.
Writing your own signed data under a bare identity proof adds one more: an exit
key cannot write. A key whose only role is controller is a continuity
instrument, and a write it signs is a 403. Use an ordinary signing key. See
Content and visibility § Writing.
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.
A 503 really is transient. A 401 or 403 on an identity DFOS has never
resolved is not — no amount of retrying makes an unknown identity known.
A write is refused before the route runs
Three statuses come from the request itself rather than from your grant, and none of them is fixed by resending the same bytes:
409— this proof'sjtiwas already accepted, so the first attempt may have succeeded. Re-read state, then retry with a freshjti. A proof with nojtiat all is a401.415— the body was notapplication/json, or carried aContent-Encodingother thanidentity.400— the body named a field this API does not serve (request bodies are closed), or the request usedX-HTTP-Method-Overrideor?_method=.
@metalabel/dfos-client at 0.54.0 or newer attaches a fresh jti to every
request except GET, HEAD, and OPTIONS through createApiAuthFetch. The
full write rules are
Conventions § Writing.
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.
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 prints one verdict and a matching exit code;
Origin binding § The four verdicts says
what each means. stale is not broken: do not treat a hosting blip as a
hijack.
Common causes, by verdict:
stale— thedfos-didfile is not deployed to production, is served on a path your host rewrites, returns a404, answers a redirect (a trailing-slash rule, awwwcanonicalization, an app-shell catch-all — a verifier never follows one, so it reads as absence), 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.
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 recomputes every check in your
own browser. #/domain/<yourhost> checks your app description, and
#/did/<your client_did> shows your identity's chain and its domain
attestation verdict. They answer different questions and can disagree without
either being wrong.
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.