Skip to main content

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 seeGo to
The consent screen refuses your appThe consent screen refuses your app
The refusal says you are not permittedA refusal that names permission
Returning users get the full consent screen againA returning user saw the full screen again
Sign-in works, but you get back no credentialSign-in works but no credential arrives
A local client's credential request is refusedA key-proven loopback request is refused
An API call returns 401401 — the request proof was refused
An API call returns 403403 — the grant was refused
An API call returns 503503 — verification could not complete
verify-binding says stale, broken, or no-claimOrigin-binding verdicts
You are not sure the server holds the right keyIs the key the server holds the key the chain published?

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:

  1. 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.
  2. 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.
  3. Does the carried chain derive client_did? A chain that folds into a different identity is refused, as is a chain present with no client_did at all.
  4. Is client_did present at all? Optional for scope=identity alone; required the moment your set contains any credential scope.
  5. 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:

  1. 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.
  2. Your app's identity changed. A different client_did answering 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 no client_did where you used to serve one counts as a change too.
  3. 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 ::1 resolve on the loopback tier, and the anonymous shape of it — no client_did — is limited to scope=identity, with a set pairing identity and a credential scope refused outright rather than quietly narrowed. A local client that wants a credential asks under the key-proven shape instead: carry client_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:

  1. The ask proof is missing, or carries the wrong typ. It must be did:dfos:siwd-ask — not the did:dfos:siwd a 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.
  2. 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 a client_did with no chain behind it, is refused rather than resolved some other way.
  3. The chain is over the carriage cap. 100 operations, the same ceiling a dfos-app.json carriage 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:

  1. 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.
  2. 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-auth exists so you do not have to.
  3. 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_KID is a full DID URLdid:dfos:<id>#key_<id>, not a bare DID and not a bare key id.
  • Its DID half equals the client_did in 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:

VerdictExitWhat it meansWhat to do
bound0A method attests this DID and none contradicts itNothing.
broken1A method answered a different DIDFix the publication. Something is answering for a DID that is not you.
stale2A claim exists and every method is silentCheck hosting, TLS, and DNS. Nobody contradicted you.
no-claim0The chain claims no domainRun 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 — the dfos-did file is not deployed to production, is served on a path your host rewrites, returns a 404, or the _dfos TXT 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-pasted dfos-did file from another deployment is still in place, or DNS carries more than one did= record under _dfos, which is a contradiction rather than a list.
  • no-claimbind-domain ran 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 derives client_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.