Skip to main content

Sign in with Poweur ID

Any website, app or service can authenticate a user by their Poweur ID. The user proves control of the key published at their name; the relying party checks the signature against the resolver chain. Nothing is registered with anyone, no token is issued by Poweur, and the verifier keeps no state but a five-minute list of spent nonces.

This page is the normative specification (EPIC-008 E08-T1). The canonical Go implementation is packages/identity/signin.go plus packages/identity/signin/; the TypeScript twin is packages/client-ts/src/signin.ts, pinned to Go by the conformance vectors in packages/identity/testdata/vectors/signin.json.

The shape of it​

  relying party                     signer (web app / CLI / mobile)          resolver
│ │ │
│ 1. build request (audience = own origin)│ │
├───── QR / deep link / redirect ─────────►│ │
│ │ 2. fetch │
│◄──── GET /.well-known/poweur.json ───────┤ RP metadata over TLS │
├──────────────────────────────────────────► (this VERIFIES origin) │
│ │ │
│ │ 3. show consent, user approves
│ │ 4. sign canonical string │
│◄──── response_uri POST / pasted code ────┤ │
│ │
│ 5. resolve identity ──────────────────────────────────────────────────►│
│ 6. verify signature, spend nonce → user is signed in │
│ │
│ 7. (optional) POST the same approval to the user's relay for a │
│ v1 resource grants removed; v2 scoped handles are planned. │

Step 7 is deliberately a separate step against a different server. Login costs the RP one signature check; access to the user's home costs an exchange at the user's relay, which is the resource server and the only party that can enforce revocation.

Request object​

The relying party builds this. It is not signed by the RP — an RP signature would prove nothing that a TLS-served /.well-known/poweur.json does not already prove, and would push every RP into key management. Authenticity of the request comes from the signer fetching the RP's metadata at audience over TLS (step 2).

{
"poweur_auth": "1",
"request_id": "req_9mJ0zvQb2hR1",
"domain": "guestbook.poweur.net",
"audience": "https://guestbook.poweur.net",
"nonce": "8Kf3s2mQvX1pQ0aB",
"issued_at": "2026-01-15T09:29:00Z",
"expires_at": "2026-01-15T09:31:00Z",
"action": "signin",
"statement": "Sign in to the Poweur Guestbook",
"response_uri": "https://guestbook.poweur.net/auth/callback",
"scopes": ["profile:read"]
}
FieldRequiredRules
poweur_authyesprotocol version, "1"
request_idyesopaque, RP-chosen; echoed in the response so a cross-device RP can match a pending login
domainnohuman-facing host; defaults to the audience's host. Display only — never trusted over the metadata fetch
audienceyesthe RP's origin. Normalized: lowercase scheme+host, default port dropped, no path, query, fragment or userinfo. http:// is accepted only for local development
nonceyessingle-use, unpredictable, ≥ 16 bytes of entropy recommended
issued_at / expires_atyesRFC3339. expires_at - issued_at ≤ 5 minutes
actionyessignin, signup or link. A closed set, because the signer must be able to say in one line what the user is approving
statementnoone line, ≤ 300 bytes, no CR/LF or control characters (the canonical string is line-oriented)
response_urinowhere the approval is delivered. MUST be same-origin with audience and MUST appear in the RP's published response_uris when it publishes any
scopesnoresource scopes for step 7; normalized, de-duplicated and sorted. Empty means login only

Transport bindings​

One encoded form serves every transport: compact JSON, base64url without padding.

TransportForm
Deep linkpoweur://auth?request=<b64url>
QR, copied linka request by reference: https://<rp>/…/<code> (below)
Web signer handoffhttps://poweur.net/app/?auth=<b64url> (URL-escaped), or ?auth=<reference>
RedirectRP navigates the browser to the signer with the same parameter
Cross-device pastethe user copies the encoded response back into the RP
Cross-device pollthe signer POSTs to response_uri with the match code; the starting page polls the RP's poll_uri with its poll secret
Same-device returnthe signer POSTs to response_uri, then opens the resume_uri from the receipt

Decoders also accept raw JSON and padded base64url: a user pasting a code should not have to know which they copied.

Requests by reference​

An encoded request is about 600 characters; as a QR code that is a dense symbol many phone cameras will not read, and as a link nobody can read out. An RP may instead serve the request at a short link on its own origin — https://oauth.poweur.org/r/K7QM4XP2 (an 8-character code, Crockford base32, living as long as the request) — and put that in the QR:

  • GET <link> with Accept: application/json answers {"request": "<encoded request>"}, with Access-Control-Allow-Origin: * (nothing in a request is secret; the match code that completes a cross-device sign-in is never served). Expired or used: 404/410.
  • A signer given the link — itself, poweur://auth?request_uri=<link>, or a web signer's ?auth=<link> — fetches it without following redirects and requires the link to be same-origin with the request's audience. The request is then exactly as authentic as one the RP handed over directly: it came from that origin over TLS.
  • The same link opened in a browser — a phone's camera app — is the RP's to answer with a page offering the signers (the OAuth bridge does), or a redirect into one (the guestbook does). No in-app scanner is needed.
  • A request by reference always comes from another screen, so signers require the match code for it.

Helpers: Go identity.SignInRequestURI, CheckSignInRequestURI, FetchSignInRequest, SignInReferenceDeepLink; TS signInRequestUri, checkSignInRequestUri, fetchSignInRequest. Vectors: signin-reference.json.

Response object​

{
"poweur_auth": "1",
"request_id": "req_9mJ0zvQb2hR1",
"identity": "alice.poweur.net",
"audience": "https://guestbook.poweur.net",
"nonce": "8Kf3s2mQvX1pQ0aB",
"issued_at": "2026-01-15T09:29:00Z",
"expires_at": "2026-01-15T09:31:00Z",
"action": "signin",
"statement": "Sign in to the Poweur Guestbook",
"scopes": ["profile:read"],
"key_id": "identity",
"signature": "…"
}

The response copies the request's validity window verbatim: an approval is valid exactly as long as the challenge was, never longer. key_id is identity for the long-lived key or session:<session id> for a delegated session key (see below), in which case session_proof is attached.

Canonical signing string​

Line-oriented, \n-joined, no trailing newline. Every field a verifier decides on is inside it:

poweur-signin
<poweur_auth>
<request_id>
<identity>
<audience>
<nonce>
<issued_at>
<expires_at>
<action>
<statement>
<scopes>
<key_id>

<statement> is the empty string when absent. <scopes> is the normalized, sorted list joined with , — sorting is what makes the string order-independent, so a signer may reorder scopes for display without breaking the signature. <identity> is lowercased and trimmed. The signature is Ed25519 over the UTF-8 bytes, base64url (verifiers accept any base64 variant, matching the rest of the protocol).

Scope vocabulary​

ScopeMeaning
profile:readread the user's public profile
messages:sendsend messages as the user

Storage path scopes and /auth/grant were removed with v1 storage. V2 resource authorization is tracked in EPIC-020. At most 16 scopes are accepted.

Verification rules​

A verifier runs these in order — cheapest and most local first, so a replayed or misaddressed response never costs a DNS or HTTPS lookup:

  1. Shape and version. poweur_auth == "1"; request_id, nonce, signature, identity present; identity is a valid Poweur name.
  2. Audience. The normalized audience MUST equal the verifier's own origin. This is the anti-phishing check and it is not optional.
  3. Action, statement, scopes. Known action; statement within limits; scopes in canonical form (reject, do not silently re-sort — a re-sorted list would verify against bytes the user never saw); unsupported scopes are rejected.
  4. Window. RFC3339; expires_at > issued_at; expires_at - issued_at ≤ 5 min; issued_at ≤ now + 2 min (clock skew); now ≤ expires_at.
  5. Nonce. Single use. Key the cache by audience|identity|request_id|nonce so one user cannot lock another out and a cache shared between RPs stays correct. Entries may be dropped once expires_at has passed — which is why the 5-minute cap exists at all.
  6. Resolution. Resolve the identity through the published chain (HTTPS well-known, then DNS TXT; key mismatch fails closed).
  7. Signature. Against the key that was valid at issued_at — the document's current key, or a previous_keys entry still inside its rotation grace window, so a sign-in signed moments before a rotation still verifies.

A verifier that keeps more than the nonce cache is doing something the protocol does not ask for.

Session-key delegation​

A daily sign-in should not touch the long-lived identity key. A response may therefore be signed by a registered session key, with key_id: "session:<id>" and the same session_proof object the relay accepts on a forwarded message:

"session_proof": {
"session_public_key": "…",
"issued_at": "2026-01-15T09:00:00Z",
"expires_at": "2026-01-15T21:00:00Z",
"nonce": "…",
"identity_signature": "…"
}

The proof is the long-lived key's signature over:

session-registration
<identity>
<session_public_key>
<issued_at>
<expires_at>
<nonce>

The verifier validates the proof chain to the identity key and then verifies the response with the session key. This is the same code path the relay runs — identity.VerifySessionProof, which the relay's acceptSessionProof also calls — so there is exactly one implementation to audit: completeness, RFC3339 timestamps, expiry, the 24-hour session TTL cap, key encoding, and the identity signature. key_id naming a session with no proof attached, or a proof attached with key_id: "identity", is rejected: the two must agree.

Delegation narrows nothing else. A session-signed approval carries the same scopes and the same five-minute window; the relay additionally refuses to mint a grant on a proof whose session has since been revoked.

Relying-party metadata​

GET https://<rp-origin>/.well-known/poweur.json

{
"poweur_auth": "1",
"origin": "https://guestbook.poweur.net",
"name": "Poweur Guestbook",
"app_id": "net.poweur.guestbook",
"response_uris": ["https://guestbook.poweur.net/auth/callback"],
"poll_uri": "https://guestbook.poweur.net/auth/poll",
"scopes": ["profile:read"],
"transports": ["redirect", "qr", "poll"],
"contact_uri": "https://guestbook.poweur.net/abuse",
"context_uri": "https://guestbook.poweur.net/auth/context"
}

This is the only "federation" document in the protocol, it is served by the RP itself, and nobody registers it anywhere. Rules a signer enforces:

  • origin must equal the origin the document was fetched from.
  • Redirects are not followed on the fetch — a redirect would let one origin answer for another, which is precisely the confusion the fetch exists to prevent.
  • logo_uri, poll_uri and every response_uris entry must be same-origin. A signer that loads a third-party logo leaks the pending approval to that third party.
  • app_id, when present, must equal the reverse-DNS of the origin host.
  • Every advertised scope must sit in the RP's own namespace.
  • context_uri, when present, must be same-origin. GET <context_uri>?request_id=… answers, while the request can still be approved, with where it was started: {"request_id", "started_at", "browser", "client", "client_host"} — a coarse browser label, never a precise location. A signer approving from another device shows it (signin.FetchContext/DescribeContext, TS fetchSignInContext/describeSignInContext) and ignores it if it cannot be fetched.
  • Body capped at 16 KiB.

An RP that publishes no response_uris accepts any same-origin one — a permissive default for a demo. Publishing the list is the hardened posture: it stops an open redirect elsewhere on the RP from turning into an approval leak.

Phishing and replay analysis​

Origin binding (the WebAuthn property)​

The signed bytes contain the verified RP origin, exactly as WebAuthn puts the origin inside clientDataJSON. Consider evil.example presenting itself as the guestbook:

  • If it sets audience: "https://guestbook.poweur.net", the signer's metadata fetch goes to the real guestbook, and response_uri — which must be same-origin with the audience and published by the RP — cannot point at evil.example. The approval is delivered to the party being impersonated, not the impersonator.
  • If it sets audience: "https://evil.example", it can collect a perfectly valid approval. That approval is signed over https://evil.example and the real guestbook's verifier rejects it at check 2. The credential is worthless anywhere but the site that minted it — the same containment WebAuthn gets from origin-scoped credentials.

The residual risk is the one WebAuthn also carries: a user who chooses to sign in at a malicious site has an account there. Nothing is stolen; nothing crosses over.

What the analysis depends on:

  • The signer must fetch RP metadata before rendering any RP-supplied string. A signer that shows domain or statement from the request alone is showing the user attacker-controlled text. domain is display sugar; the metadata name and the audience host are the trusted labels.
  • A signer must never accept response_uri off-origin. This is the confused-deputy guard, enforced twice: at request validation and against the RP's published list.
  • http:// audiences are development-only. A signer SHOULD warn loudly, and a production RP MUST publish over TLS — without TLS the metadata fetch proves nothing.

Replay​

The nonce is single-use and the window is ≤ 5 minutes, so a captured approval is replayable only inside that window and only until the honest verifier spends the nonce. Two things follow:

  • A multi-process RP needs a shared nonce cache (Redis, a unique index in Postgres). A per-process cache permits one replay per process. The SDK's default in-memory cache is correct for a single process and documented as such.
  • The cache must be atomic: two concurrent claims of the same key must not both succeed, or the guard has a race an attacker can drive.

A verifier that cannot reach its cache must fail closed.

Not addressed here​

Malware on the signing device, a compromised RP after login, and a hostile relay serving a forged identity document are out of scope. The last is mitigated by the resolver chain's fail-closed key-mismatch rule (EPIC-001) and by contact key pinning (EPIC-007); the first two are the same trust assumptions every login protocol makes.

Who may complete a sign-in​

Added while designing the OAuth bridge (EPIC-022), after a review found the reference RP handing its session to whoever started a login rather than to whoever approved it. The Go helpers are in packages/identity/signin/delivery.go; the TypeScript twins are checkResumeUri and normalizeMatchCode in @poweur/client.

Anyone may create a request, and that is fine. A request is a challenge: it carries no authority, it names no user, and holding one grants nothing. The protocol also cannot demand a signature on this first leg — until the challenge exists there is nothing to sign, and it is the relying party that must mint it. Asking "how do we stop a stranger starting a login?" therefore has no answer. The right question is who may finish one:

A sign-in completes only for a browser that presents both proof that it started the transaction and a completion secret that was handed exclusively to the signer.

Two halves, deliberately held in two places. In the ordinary same-browser journey both live in the one browser, and nothing changes for the user. When the two halves are in different places, the login simply does not complete — which is exactly the outcome wanted.

The attack this closes​

An attacker starts a login at an RP, keeps the pending transaction, and sends the victim the signer link (https://poweur.net/app/?auth=…). The signer honestly displays the real RP's name, because the request really is for that RP, so a victim expecting to sign in there may well approve.

HoldsAttackerVictim
Transaction / initiator binding✅ started it❌
Completion secret (minted on approval, returned to the signer)❌✅

Note what does not help: binding the pending login to the browser that started it. The attacker is the initiator, so any initiator-only binding authenticates the attacker. The binding that matters is to the approver's device — which is why the completion secret must be returned to the signer, and never be derivable from the request.

Mechanism​

The approval — not the page that started the sign-in — decides how it finishes, because only the approval can say whether the approver was looking at the starting screen.

When the RP creates a request it keeps a transaction holding: the hash of a binding cookie it sets on the starting browser, the hash of a poll secret it gives the starting page, and a two-digit match code that page displays.

The signer POSTs a delivery to response_uri:

{ "response": "<encoded approval>", "match": "42" }

match is present only when the user says they started on another screen. A delivery without it may also be the bare encoded approval as the whole body (the original wire form). The RP processes one approval per transaction and then:

DeliveryRP checksReceiptFinishes at
without matchsignature{"status":"ok","resume_uri":"…?code=…"}resume_uri, only with the binding cookie
with matchmatch code (one attempt), then signature{"status":"ok"}the starting page's poll, only with the poll secret
  • The resume code is ≥128 bits, single-use, and short-lived (the guestbook allows one minute).
  • The signer checks resume_uri is same-origin with the audience it verified, then navigates the same browser there. It never puts the approval itself in a URL, and an RP never accepts one there.
  • A wrong match code ends the transaction; the right code afterwards does not revive it.
  • A poll before a same-device approval is resumed reports approved and hands out nothing.

This is additive to the signed protocol: the request object, canonical signing string, signature and test vectors are unchanged. It governs only how an already-signed approval is redeemed, so no conformance vector moves. match is deliberately outside the signed bytes — it proves where the approver was looking, not who they are.

Cross-device approval is weaker, and must say so​

When the approving device is not the browser being signed in — QR and poll transports — no completion secret can reach the initiating browser without also being available to whoever forwarded the request. This is the residual risk in every QR login, and it is handled with disclosure rather than cryptography:

  • The poll handle is a high-entropy secret bound to the initiating browser, never request_id (which the initiator hands out by construction). This stops a bystander who saw the QR from claiming the session; it does not stop a forwarded request.
  • The signer shows number matching against the initiating screen, plus the initiator context the RP recorded: coarse location, browser and how long ago the login started.
  • The signer states plainly that the user is approving a sign-in started somewhere else, and says to cancel unless the code is on a screen in front of them.
  • Short TTL, single use, and no silent re-issue of a request inside one transaction.

Where it is implemented​

ComponentBehaviour
Web signer (apps/web)optional code field (required in the mobile shell); follows resume_uri; no approval in any URL
Go CLIpoweur auth approve --code <digits>; prints the resume link for a same-device approval

Initiator context is served by the guestbook and the OAuth bridge (context_uri) and shown by the web signer and poweur auth approve: which browser started the sign-in, how long ago, and — for the bridge — which application it is for. Coarse location is not offered.

Scoped resource grants​

The v1 resource grant exchange is removed. Planned node-scoped resource access is tracked in EPIC-020; see Connected apps.

Test vectors​

packages/identity/testdata/vectors/signin.json pins the protocol for both implementations: canonical strings, and a labelled case per rule —

CasePins
valid-identityhappy path, identity-key signature
valid-session-delegatedsession-key signature + proof chain
valid-no-scopeslogin-only approval, empty scope line
expirednow > expires_at
ttl-exceededwindow longer than 5 minutes
wrong-audiencesignature valid, audience is another origin
replayed-noncesecond presentation of an accepted response
scope-out-of-namespacedav: scope outside apps/<app id>
scopes-unsortednon-canonical scope order
tampered-statementstatement edited after signing

Regenerate with pnpm vectors. Go is canonical; TypeScript conforms.