---
name: w3ds-auth-in-practice
description: "Use when implementing or debugging w3ds://auth — the login QR or deeplink, same-device login on a phone, the /deeplink-login return page, running without hosting on localhost or a tunnel, the callback endpoint, ECDSA signature verification, key-binding certificates, or the wallet showing 'Authentication failed. Please try again.' Complements the base skill's protocol reference with the encodings, request shapes and failure modes measured against real wallet builds."
license: Apache 2.0
---

# `w3ds://auth` as the wallet actually performs it

The base skill describes the flow and points at the signature-format reference. What follows is what real wallet builds were observed doing, and the failure modes that produce the single unhelpful message the user sees: **"Authentication failed. Please try again."**

That message means the wallet sent its callback and got a non-2xx, or could not reach you at all. It never tells you which.

## Before any of this: the app needs an address the wallet can reach

`http://localhost:<port>` cannot work, and no amount of correct cryptography will rescue it.
The wallet runs on the user's phone and calls your callback **from the phone, over the
network**. On that phone `localhost` is the phone. Your server never hears from it, the
session stays `pending`, and the screen says *waiting for confirmation* until the user
gives up. This is the single most common cause of "eID login does not work" in a project
that has no hosting yet, and it is invisible: every service involved is healthy.

So a publicly reachable base URL is a **precondition of the first line of auth code**, not
a deployment concern to settle later.

While there is no hosting, use a tunnel. Cloudflare needs no account:

```bash
cloudflared tunnel --url http://localhost:3006
```

It prints `https://<random-words>.trycloudflare.com`. That string becomes the application's
base URL — the one the offer's `redirect=` is built from, and the one the user opens in
their phone's browser.

```ts
const baseUrl = process.env.PUBLIC_BASE_URL;            // https://….trycloudflare.com
const redirectUrl = new URL("/api/auth/login", baseUrl).toString();
const offer = `w3ds://auth?redirect=${redirectUrl}&session=${sessionId}&platform=<name>`;
```

Three consequences worth writing down before they cost a day:

- **A free tunnel's hostname changes on every restart.** Restart it and the offer still
  advertises yesterday's host, so the wallet posts into the void. Symptom: *it worked
  yesterday*. Re-read the URL and restart the API whenever the tunnel restarts.
- **Frontend and API must share one public origin.** A page served from the tunnel that
  calls `http://localhost:3006` is unreachable from the phone that loaded the page.
- **The tunnel is for development only.** Once hosting exists, the base URL is the real
  domain and the tunnel goes away.

Verify the address yourself — from outside — before asking a human to try anything:

```bash
curl -s -o /dev/null -w "%{http_code}\n" https://<host>/api/auth/offer   # expect 200
```

## Debug in this order

Cheapest first. Most reported failures are the first two, and neither is cryptography.

0. **Reachability.** Is the base URL in the offer resolvable from the public internet? A `localhost` or stale-tunnel host produces an indistinguishable symptom and is the most common cause of all. See the section above.
1. **Session expiry.** A login session is short-lived — five minutes in our implementation. Generate a QR, go read some code, then scan: expired, and the wallet says *Authentication failed*. When debugging by hand this fires constantly and looks like a signature problem.
2. **The return path.** See *the redirect is a hint* below. An unhandled return path is indistinguishable from "not signed in."
3. **Public-key encoding.** See below. This is the failure that produces a confident wrong diagnosis, because the error text mentions the key and everyone reads it as the signature.
4. **Signature encoding.**
5. Transient infrastructure.

## The wallet sends GET *or* POST. Both were observed.

Two real callbacks from production logs, same deployment:

```
method: 'GET',  path: '/deeplink-login',    contentType: ''
method: 'POST', path: '/api/auth/callback', contentType: 'application/json'
```

- **GET**: parameters in the query string — `?ename=…&session=…&signature=…`, no body, empty `Content-Type`.
- **POST**: JSON body.

Accept both. Accept `application/x-www-form-urlencoded` too as a fallback — defensive, no observed instance.

The eName field arrives as `ename` **or** `w3id`. Read both.

### The `redirect` you put in the offer is a hint, not a contract

In the GET case above, the offer had advertised `/api/auth/callback`. The wallet went to **`/deeplink-login`** and ignored the `redirect` parameter entirely. Different builds hard-code different paths.

Register **every known convention**, each for GET *and* POST, plus `OPTIONS`:

```
/api/auth   /api/auth/callback   /auth/callback   /deeplink-login   /callback
```

A path you do not handle dead-ends into your HTML auth gate, which bounces the user to the sign-in page — which is exactly how same-device login fails while every service is healthy.

### CORS is needed on the callback response, not only the preflight

The wallet preflights before it POSTs. A path that answers the POST but not its `OPTIONS` fails the whole login without the POST ever arriving. Answer `OPTIONS` on all five paths, and send the CORS headers on the **POST response** as well.

### In the GET flow the browser can arrive before the signature

These are two independent channels: the wallet returns the browser to you, and the signed assertion travels as a separate server-to-server POST. When the browser lands and the session is still `pending`, you must neither admit nor reject — serve a bridge page that polls the session until it resolves.

Also: in the GET flow, **serve HTML, not a 303**. In-app webviews handle a rendered page with an explicit navigation far more reliably than a redirect chain.

## The browser side of the same-device return

Everything above is about the server-to-server channel. The other channel — returning the
**person** to your page — is where same-device login actually fails, and it fails on iPhone
most reliably of all. Four parts, none optional.

**1. `/deeplink-login` must exist as a real page.** Not a redirect, not a route that falls
through to the auth gate. Observed wallet builds return the browser there regardless of what
the offer advertised. No page → the user lands on *not found* immediately after approving,
and reads it as your app being broken.

**2. Mobile browsers suspend a background tab, so SSE dies while the user is in the wallet.**
An event-stream that waits for "logged in" is fine on a desktop and useless on a phone: it is
severed the moment the wallet comes to the foreground, and it does not resurrect on return.
The phone needs **polling** — `GET /api/auth/status?session=<id>` every ~1.5s for about a
minute — with SSE kept only as the desktop fast path.

**3. Save the session id before handing control to the wallet.** The return page may arrive
with no parameters at all, and then the only way to know which session to ask about is what
you stored:

```js
localStorage.setItem('w3ds.sessionId', sessionId);   // before opening the offer
```

Resume on return, for the build that reuses the same tab rather than opening
`/deeplink-login`:

```js
const onVisible = () => { if (!document.hidden) checkStatusOnce(); };
document.addEventListener('visibilitychange', onVisible);
window.addEventListener('focus', onVisible);
```

**4. The return page reads parameters from the query string *and* from the fragment.** Both
were observed; a page that only reads `location.search` silently finds nothing on the builds
that use `#`:

```js
let search = window.location.search;
if (!search && window.location.hash.includes('?')) {
  search = window.location.hash.slice(window.location.hash.indexOf('?'));
}
```

If the parameters are there, relay them to `/api/auth/login` yourself; if they are not, fall
back to polling `/api/auth/status` with the stored session id.

### The login endpoint must be idempotent, or success is followed by an error

The return page relaying parameters means `/api/auth/login` gets called **twice** for one
login — once by the wallet, once by the page. Without a per-session result cache the second
call finds the session already consumed and answers with an error, which the user sees
*after* having successfully logged in. Cache the result under the session id and return the
cached result on any repeat call:

```ts
const cached = sessionResults.get(session);
if (cached) return res.json(cached);       // before consuming anything
```

### Mobile gets a link, not a QR

A phone cannot scan its own screen. The login screen must branch: QR (plus SSE) on a desktop,
a plain tappable link straight to the `w3ds://auth` offer on a phone.

### Neither scenario is done until a person has run it

Two acceptance scenarios, both performed by a human on real devices — a description of the
code is not evidence:

- **Desktop:** open the app on a computer → scan the QR with the phone → approve →
  **the computer is logged in**.
- **Same device:** open the app **in the phone's browser** → tap the login link → approve in
  the wallet → **the browser returns and the app is logged in**.

The second one is the one that has never worked first time. When it passes, close the tab,
reopen, and run it again: it must work every time, not one time in three.

## What the wallet counts as success

**The HTTP status, and nothing else.** Evidence: on POST we answer `200 {ok:true, eName}`; on GET we answer an HTML page. Both are accepted. So no particular JSON shape is required.

The status on failure matters too:

- **4xx** → the wallet gives up and shows *Authentication failed*.
- **503** → the wallet retries by itself. This is what makes the transient rule below work.

## What is signed

**The bare session id, as a UTF-8 string.** Nothing wrapped — no `<platform>:<session>`, no JSON envelope, no pre-hash. SHA-256 is applied by ECDSA itself.

The session id **is** the challenge. Generate it server-side, keep it single-use, and let trust flow only from a valid signature over it — never from the eName in the callback, which is merely a claim about who signed.

## `platform` needs no registration

Any string works; the wallet renders it on the consent screen and validates nothing. Measured: a platform absent from `registry/platforms` still got its name and domain displayed, and the wallet proceeded to sign and post. The failure came from the platform's own server.

If you care how the name looks to the user, do not normalise it — our own implementation lowercases it and replaces spaces with dashes, which is fine for a machine label and ugly on a consent screen.

## The public key is `0x`-prefixed hex, not multibase

This is the one that costs a day.

`GET <evault>/whois` with `X-ENAME: @<uuid>` returns `keyBindingCertificates` — an array of **JWTs** (`alg: ES256`, `kid: entropy-key-1`). Verify each against the registry JWKS at `/.well-known/jwks.json`, then read its payload:

```json
{"ename":"@ad0c3d86-…","publicKey":"0x04685ad0ef23dc84…","iat":…,"exp":…}
```

`publicKey` measured as `0x` **+ 130 hex characters = 65 bytes, first byte `0x04`** — an uncompressed SEC1 point on P-256.

Not multibase `z…`. Not base64. Not SPKI DER. Not a JWK. Every did:key example in circulation expects multibase, which is why an implementation that follows the examples fails with an error naming the key:

```json
{"error":"unrecognised public key encoding"}
```

Load it by content, not by assumption:

```js
const raw = Uint8Array.from(pk.replace(/^0x/, "").match(/../g).map(h => parseInt(h, 16)));
// 65 bytes starting 0x04 → raw SEC1 point
const key = await crypto.subtle.importKey(
  "raw", raw.buffer, { name: "ECDSA", namedCurve: "P-256" }, false, ["verify"]
);
// a value starting 0x30 is SPKI instead → importKey("spki", …)
```

Both forms occur. Decoding code that looks redundantly defensive here is not — each branch was met in live data. Do not tidy it.

The certificate also **expires in an hour** (measured `iat` 16:10:48 → `exp` 17:10:48 UTC). Honour `exp`; do not cache past it.

Finally, match `payload.ename` against the eName being authenticated. A verified certificate for a *different* subject proves nothing about this login.

## The signature may be DER

WebCrypto's ECDSA verify wants **raw `r ‖ s`, 64 bytes**. Observed inputs include base64 raw, multibase base58btc (`z…`), and **DER** (starts `0x30`). Detect DER and convert before verifying, or a correct key still fails every certificate.

## An empty certificate array must be a hard refusal

This is the most serious item in this file, and it is a vulnerability rather than a bug.

If `keyBindingCertificates` is empty, the vault **cannot have signed anything**, so there is nothing to verify against and the only sound answer is *no*. The reference client returns `valid: true` in that case, and a port of it inherited the same behaviour — meaning **any string at all logs in as any eName whose vault has no bound key**.

Measured 2026-08-19: the ecosystem's own signature validator accepts the literal string `"this-is-not-a-signature-at-all"` against such a vault.

Which vaults are in that state?

- **Every platform vault, always** — the provisioning key never becomes a binding.
- **Every freshly provisioned personal vault**, until its wallet binds a key. Which is precisely the window in which an account is worth stealing.

Check this line in your own code before anything else in this file.

## Transient infrastructure is not a failed login

Registry and eVault answer 429/502/503 under load, and real backoffs reach tens of seconds. Honour `Retry-After`; retry within a **time budget**, not a fixed count. (An earlier version of ours did four attempts totalling 2.4 seconds against a service whose measured backoffs reached fifty — it read as careful and could not survive one real rate-limit.)

Crucially: a transient failure must **not close the session**. Leave it `pending` and answer **503**, so the wallet retries on its own. Rejecting on a 429 blames the owner's signature for someone else's traffic and sends them back to rescan the QR.

## Two adjacent traps worth knowing

- **An unauthenticated eVault data query returns a generic `Unexpected error` / `INTERNAL_SERVER_ERROR`, not a 401.** Introspection and `{ __typename }` still answer 200, so the server looks healthy while every data query fails. This reads exactly like an outage and is not one. Before declaring the eVault down, print whether your process actually holds the developer key.
- **A vault's resolved URI may be plain `http://`.** Measured: `registry/resolve` returned `http://<ip>:4000` for a live vault. An HTTPS page cannot call that at all — the browser blocks mixed content before CORS is even consulted. Any browser-only design that reads a vault directly is blocked on this, regardless of how permissive the eVault's CORS is (and it is permissive: it reflects arbitrary origins and allows `X-ENAME`). A thin server-side proxy is the way through — which is also where the developer key belongs, since shipping it to a browser publishes a platform-wide secret.

## Checklist

- [ ] All five return paths are routed, for GET and POST, with `OPTIONS` answered and CORS headers on the POST response.
- [ ] Both `ename` and `w3id` are read; GET takes parameters from the query string.
- [ ] The signed payload is the bare session id; trust flows only from the signature.
- [ ] Certificates are verified against the registry JWKS, `exp` is honoured, and `payload.ename` is matched to the subject.
- [ ] The public key is decoded by content (`0x04…` raw, `0x30…` SPKI), and DER signatures are converted to `r ‖ s`.
- [ ] An empty `keyBindingCertificates` array is refused.
- [ ] Transient upstream failures leave the session pending and answer 503.
- [ ] The GET flow serves HTML and polls when the session is still pending.
- [ ] The base URL in the offer is publicly reachable and was verified with a request from outside.
- [ ] `/deeplink-login` exists as a real page, reads parameters from query **and** fragment, and falls back to polling with a session id kept in `localStorage`.
- [ ] `/api/auth/login` is idempotent per session, so the second call returns the first call's result.
- [ ] Both login scenarios — desktop-by-QR and same-device — were performed by a person, and the same-device one repeated.
