Skip to main content

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 seeGo to
The consent screen refuses your appThe consent screen refuses your app
The refusal says you are not permittedA refusal that names permission
The refusal says your chain is older or divergesA refusal that says your chain is older or diverges
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
A write returns 409, 415, or 400A write is refused before the route runs
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 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-app shows 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 ::1 resolve on the loopback tier, and its anonymous shape — no client_did — is limited to scope=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:

  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 (what the request carries). Check that you signed with the ask helper, not the sign-in one.
  2. The chain is missing or does not derive the named client_did. A client_did DFOS has never held, sent with no client_chain, is refused, and so is a chain from a different identity. It is checked by the same rules a well-known carriage is.
  3. 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.
  4. 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:

  1. 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.
  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.
  4. DFOS has never resolved the identity you signed as. A DID this platform has never seen is a 401 — or a 403 when it is the identity behind your credential — and never a 503. 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 in GET /v1/credential.
  • The content is not the granting user's. write:posts and write:comments reach that user's own posts and comments and nothing else — editing or deleting another member's content is a 403 whatever 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's jti was already accepted, so the first attempt may have succeeded. Re-read state, then retry with a fresh jti. A proof with no jti at all is a 401.
  • 415 — the body was not application/json, or carried a Content-Encoding other than identity.
  • 400 — the body named a field this API does not serve (request bodies are closed), or the request used X-HTTP-Method-Override or ?_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_KID is 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_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 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 — the dfos-did file is not deployed to production, is served on a path your host rewrites, returns a 404, answers a redirect (a trailing-slash rule, a www canonicalization, an app-shell catch-all — a verifier never follows one, so it reads as absence), 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-claim — bind-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.

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.