After a normal Solid-OIDC login, a Solid application registers a W3C WebAuthn (passkey) credential — bound to the application's own origin — with the user's OpenID Provider (OP). Thereafter the application re-authenticates without an interactive redirect: it fetches a single-use challenge from the OP, produces a WebAuthn assertion in the application's own page, and exchanges the assertion at the OP's token endpoint using OAuth 2.0 Token Exchange ([[RFC8693]]) for ordinary Solid-OIDC, DPoP-bound tokens ([[RFC9449]]). Because the credential is origin-bound and the user agent refuses to release it to any other origin, one assertion attests both the user (possession of the authenticator) and the client application (its origin, signed unspoofably into clientDataJSON). Resource Servers (Pods) are unchanged: they consume the issued DPoP-bound token exactly as any other, trusting the OP via the existing solid:oidcIssuer link.

This document is an unofficial editor's draft. It has no standing whatsoever: it is not a work item of the W3C Solid Community Group and is not endorsed by that group, by W3C, or by anyone else. It is published to solicit review before any proposal for adoption.

This specification is implementation-first: its normative requirements are those enforced by the reference implementations — the OpenID Provider component [[CSS-WEBAUTHN]] and the client / wire-format library [[REAUTH-IMPL]] — with a small number of clearly marked additions. records, requirement by requirement, which parts are implemented and which are proposed.

Drafting disclosure: this draft was prepared with AI assistance (Claude Fable 5, Anthropic), working from the jointly-authored design corpus ([[SOLID-WEBAUTHN-ARCH]], its ADR log, and the reference implementations), and is awaiting review by its human editor. Errors introduced in drafting are the tooling's, not the cited sources'.

Introduction

This section is non-normative.

Solid-OIDC [[SOLID-OIDC]] authenticates a user to a Solid application through an interactive, redirect-based OpenID Connect flow. Once the resulting tokens expire, the application must re-authenticate — and a full top-level redirect to the OP is disruptive: it unloads the application, loses in-memory state, and trains users to type credentials into whatever page appears. This specification defines a redirect-free re-authentication mechanism built on W3C WebAuthn Level 3 [[WEBAUTHN-3]]: a fast, phishing-resistant challenge–response handled entirely between the application and the OP, surfaced to the user as at most a passkey prompt.

Two facts fix the architecture and are assumed throughout (rationale and threat analysis in [[SOLID-WEBAUTHN-ARCH]] §3.3):

F1 — the application is the Relying Party.
A WebAuthn assertion's RP ID is bound by the user agent to an origin the Relying Party controls. A different-origin OP cannot be the RP for an app-bound credential. The ceremony therefore runs in the application, and the OP verifies a relayed assertion. This is why the OP cannot host the ceremony as its own login page — doing so would make the OP the RP and destroy the client-attestation guarantee.
F2 — a WebAuthn key cannot sign HTTP requests.
The authenticator signs only a server challenge it wraps in authenticatorData and clientDataJSON, with a user gesture each time. WebAuthn is a challenge–response login primitive — which is exactly how it is used here, and why per-request request-signing is out of scope.

This document is the normative wire specification. The non-normative companion — overview, rationale, C4 diagrams, and the full STRIDE / LINDDUN / attack-tree threat analysis — is the architecture document [[SOLID-WEBAUTHN-ARCH]]. Where the two disagree, this document governs.

Relationship to existing specifications

This section is non-normative.

This specification defines three conformance classes — Application, OpenID Provider, and Resource Server — enumerated in . Requirements phrased against a class bind every implementation of that class.

Conformance classes

This specification defines three conformance classes. The terms are used in the sense of [[SOLID-OIDC]].

Application (the WebAuthn Relying Party)
A Solid-OIDC client running in a user agent at an HTTPS origin, identified by a Client ID Document URI. It is the WebAuthn Relying Party: it runs the registration and assertion ceremonies via navigator.credentials and relays the results to the OpenID Provider. Conformance requirements: , , , (the client-side requirements, including req-client-downgrade).
OpenID Provider (the verifier, key store, and grant)
A Solid-OIDC OP that additionally stores registered passkey public keys, issues challenges, verifies relayed assertions, and mints tokens via the re-authentication grant. Conformance requirements: , , , , , , , .
Resource Server (explicitly unmodified — a non-target)
A Solid-OIDC Resource Server (Pod). This specification imposes no requirements on it: it consumes the issued DPoP-bound Solid-OIDC access token exactly as it consumes any other, trusting the OP via the existing solid:oidcIssuer link [[SOLID-OIDC]]. That zero-change property is a design goal; a Resource Server that is entirely unaware of WebAuthn is conforming.

Terminology

This document uses the wire structures and ceremonies of [[WEBAUTHN-3]]: clientDataJSON, authenticatorData, the UP (User Presence) and UV (User Verification) flags, the signature counter (signCount), the COSE credential public key, and the JSON serialization of ceremony results (PublicKeyCredential.toJSON()RegistrationResponseJSON / AuthenticationResponseJSON).

origin
A web origin tuple — scheme, host, and port — as observed by the user agent and serialized into clientDataJSON.origin. It is set by the user agent and cannot be forged by script. An origin is not a client_id, and the two MUST NOT be compared as strings ().
client_id
A Solid-OIDC Client ID Document URI (an HTTPS URI), e.g. https://app.example/clientid.jsonld [[SOLID-OIDC]]. It identifies the Application.
allowed-origin set
The set of origins from which an assertion is accepted for a given client_id, derived per .
assertion bundle
The versioned envelope around a WebAuthn assertion, carried as the [[RFC8693]] subject_token ().
canonical base64url
The unpadded base64url encoding of [[RFC4648]] §5 (as used throughout [[WEBAUTHN-3]]), with the additional property that the string is the unique encoding of its byte content: its length modulo 4 is never 1, and any unused low-order bits of the final character are zero — equivalently, decoding then re-encoding the string yields the identical string. See .
per-client RP ID
The WebAuthn RP ID an OP expects for a given client_id: the lowercased host of the Client ID Document URI, with no port ().

Protocol overview

This section is non-normative.

Registration runs once per ⟨application, device⟩, after a normal Solid-OIDC login:

User            Application (RP)                 OpenID Provider
 |                    |                                 |
 |                    |-- POST register-options ------->|   (authenticated)
 |                    |<- creation options -------------|   challenge, rp.id = per-client RP ID,
 |                    |                                 |   UV required, residentKey required
 |<-- gesture (UV) ---|  navigator.credentials.create() |
 |--- attestation --->|                                 |
 |                    |-- POST register {credential,    |
 |                    |        clientId} -------------->|   verify origin ∈ allowed-origin set,
 |                    |                                 |   per-client RP ID, challenge (single-use);
 |                    |<- 201 Created ------------------|   store ⟨WebID, client_id, credentialId,
 |                    |                                 |          COSE key, signCount⟩

Re-authentication runs whenever the application needs a fresh token, with no top-level navigation:

Application (RP)                OpenID Provider                    Resource Server (Pod)
 |                                     |                                 |
 |-- GET /resource (expired token) ------------------------------------>|
 |<- 401 WWW-Authenticate: DPoP ----------------------------------------|  (ordinary Solid-OIDC)
 |                                     |                                 |
 |-- POST assertion-options ---------->|   fresh single-use challenge,   |
 |<- request options ------------------|   allowCredentials: [],         |
 |                                     |   userVerification: required    |
 |   navigator.credentials.get()       |   (passkey prompt; discoverable |
 |   → assertion                       |    credential selects the user) |
 |                                     |                                 |
 |-- POST token endpoint ------------->|   grant_type = token-exchange,  |
 |   subject_token = assertion bundle  |   DPoP proof on the request     |
 |   subject_token_type =              |                                 |
 |     urn:solid:token-type:           |   verify: decode (fail-closed), |
 |       webauthn-assertion            |   embedding, consume challenge, |
 |                                     |   resolve credential → WebID,   |
 |                                     |   WebAuthn L3 §7.2 (origin,     |
 |                                     |   rpIdHash, UV, sig, signCount) |
 |<- { access_token, token_type:      |                                 |
 |     "DPoP", issued_token_type }     |   mint DPoP-bound token,        |
 |                                     |   webid = resolved WebID        |
 |-- GET /resource, Authorization: DPoP <token> + DPoP proof ----------->|
 |<- 200 ----------------------------------------------------------------|

The credential is discoverable (a resident passkey): the assertion-options request is unauthenticated and returns an empty allowCredentials, the authenticator selects the passkey, and the OP resolves who the user is from the assertion itself at the token exchange.

The per-client RP ID and origin model

Each Application is its own WebAuthn Relying Party.

The OpenID Provider MUST derive the expected WebAuthn RP ID per request from the authenticated client_id: the lowercased host of the Client ID Document URI, with no port. It MUST do so consistently at registration (), at assertion-options (), and at verification (). It MUST NOT substitute a single deployment-wide RP ID when a host is derivable from the client_id (always the case for a valid http(s) URI). A deployment-wide RP ID MAY be configured as a fallback used only when no host is derivable.

The OpenID Provider MUST compute the allowed-origin set for a client_id as exactly the single normalised origin of the client_id URI. Normalisation lowercases the scheme and host, elides the default port for the scheme, and discards any path, query, or fragment. Origins declared inside the Client ID Document are out of scope for this version and MUST NOT be added to the allowed-origin set (they require a proof-of-control mechanism deferred to a future profile; see ).

The OpenID Provider MUST verify, at registration and at every assertion, that clientDataJSON.origin (after the same normalisation) is a member of the allowed-origin set for the authenticated client_id. It MUST NOT compare clientDataJSON.origin to the client_id as a string. This check is the phishing-resistance gate.

Because the verifier asserts rpIdHash == SHA-256(expected RP ID) and the expected RP ID is derived from the authenticated client_id, the triple ⟨signed rpIdHash, the registered credential's client_id, the authenticated client_id⟩ is a checked invariant: an assertion whose rpIdHash is for a host other than the authenticated client's fails verification. This is what lets one OP serve multiple applications without the origin check being the sole per-app discriminator ([[SOLID-WEBAUTHN-ARCH]] §8.2).

Registration

Registration binds a passkey to a ⟨Application, device⟩ pair and stores its public key at the OpenID Provider. It runs over two authenticated endpoints, reached after a normal Solid-OIDC login; in the reference implementation [[CSS-WEBAUTHN]] they are POST /.account/account/<id>/webauthn/register-options and POST /.account/account/<id>/webauthn/register. The endpoint paths are not normative; their behaviour is.

Registration options

On an authenticated request naming the target client_id (and, when the account has several, the WebID), the OpenID Provider MUST return WebAuthn PublicKeyCredentialCreationOptions (the JSON serialization of [[WEBAUTHN-3]]) carrying:

The user handle (user.id) MUST be an opaque per-account value that is not derivable from the WebID; in particular it MUST NOT be SHA-256(WebID) or any other deterministic function of the WebID, which would be an offline-confirmable cross-application correlator (). The reference implementation mints a random per-account handle and persists it.

Registration verification

On the registration request carrying the attestation (the WebAuthn RegistrationResponseJSON plus the declared clientId), the OpenID Provider MUST, in order:

  1. reject the request unless the bundle's declared clientId equals the authenticated client_id;
  2. reject the request unless clientDataJSON.crossOrigin is false or absent and no topOrigin is present (req-no-embed);
  3. atomically consume the registration challenge (), checking the ⟨WebID, client_id⟩ binding it was issued with; reject if it is unknown, expired, already used, or bound to a different context;
  4. verify the attestation per [[WEBAUTHN-3]] §7.1 with the expected challenge, the allowed-origin set (req-origin-check), the per-client RP ID (req-rpid-per-client), and User Verification required (req-uv-reg);
  5. on success, store the credential record — credentialId, the COSE credential public key, signCount, and any transports — keyed by ⟨WebID, client_id, credentialId⟩.

Attestation conveyance of none is acceptable. Multiple credentials MAY be registered per ⟨WebID, client_id⟩ (one per device).

The assertion-options endpoint

The OpenID Provider MUST expose an assertion-options endpoint that returns WebAuthn PublicKeyCredentialRequestOptions (JSON serialization) for the redirect-free re-authentication ceremony. In the reference implementations the conventional location is POST <issuer>/.oidc/webauthn/assertion-options; the path is not normative and MAY be deployment-configured, but the conventional default aids interoperability with clients that discover nothing.

This endpoint:

Because this challenge is issued before any client or WebID is authenticated, it is issued unbound; single-use consumption () together with the per-client RP ID, origin, and credential checks at verification are the controls on this path.

CORS. On the registration endpoints (), where the target client_id is known from the authenticated request, cross-origin responses MUST carry CORS headers restricted to that client's allowed-origin set; an OpenID Provider MUST NOT use Access-Control-Allow-Origin: * there. On the assertion-options endpoint no client is identified (the request is unauthenticated and bodyless), so a per-client restriction is not computable: an OP SHOULD restrict Access-Control-Allow-Origin to a configured allowlist or to the union of the allowed-origin sets of clients holding at least one registered credential, and MAY otherwise reflect the request's Origin. Responses from the assertion-options endpoint MUST NOT require or include credentials (cookies or HTTP authentication; Access-Control-Allow-Credentials MUST NOT be true where the allowed origin is reflected).

The challenge is deliberately not a secret: possession of a challenge grants nothing — every security check happens at the token exchange (), and the challenge is single-use (). CORS restriction on the assertion-options endpoint is abuse hygiene (it complements rate limiting), not a security boundary.

The assertion bundle

The re-authentication grant carries the WebAuthn assertion as the [[RFC8693]] subject_token. Its subject_token_type is the URI

urn:solid:token-type:webauthn-assertion

defined by this specification (; [[RFC8693]] §3 permits any URI as a token-type identifier, so no IANA registration is required for this version).

The subject_token value MUST be the unpadded base64url encoding ([[RFC4648]] §5) of the UTF-8 JSON serialization of the assertion bundle, a versioned envelope with exactly these members:

version
A JSON number. This version of the specification defines version 1. An OpenID Provider MUST reject a bundle whose version it does not support ().
credential
The WebAuthn AuthenticationResponseJSON produced by PublicKeyCredential.toJSON() [[WEBAUTHN-3]]: id, rawId, type ("public-key"), and a response object carrying base64url clientDataJSON, authenticatorData, and signature, with an optional userHandle.

Reusing the W3C WebAuthn JSON serialization means user agents and WebAuthn libraries produce and consume the inner credential directly; no bespoke encoding is introduced.

Fail-closed decoding

The decode path is a security surface: the OpenID Provider runs it on the untrusted subject_token before any cryptography. An OP MUST treat a subject_token that is not valid base64url, not valid UTF-8, not valid JSON, not a JSON object, of an unsupported version, or missing or ill-typed in any required credential field as a malformed bundle and reject it with invalid_request () without proceeding to any later verification step. Specifically, before cryptographic verification the OP MUST check that:

These checks are structural only — they guard the parse, not the content; the WebAuthn signature re-authenticates the bytes (). Their purpose is that a malformed credential yields a clean, uniform invalid_request rather than a deeper exception, and that no non-canonical string reaches a string-keyed lookup.

Canonical base64url

Every base64url field above MUST be canonical base64url: unpadded [[RFC4648]] §5 base64url whose decoding re-encodes to the identical string. An OpenID Provider MUST reject (as a malformed bundle) any field that is:

  1. padded (= is outside the unpadded alphabet), or contains any character outside A–Z a–z 0–9 - _;
  2. of an impossible length — unpadded base64url packs 4 characters into 3 bytes, so a final group of exactly 1 character (length mod 4 == 1) can never be produced by an encoder; or
  3. non-canonical in its final character — for a final group of 2 characters the last character's low 4 bits, and for a final group of 3 characters the last character's low 2 bits, are unused and MUST be zero.

A single decode → re-encode round-trip detects all three classes: only a canonical string re-encodes to itself.

Why this matters. Classes 2 and 3 are alphabet-valid strings no encoder emits. A non-canonical string such as "AB" decodes to the same bytes as its canonical form "AA" yet is a distinct string — an aliasing / malleability hazard wherever the string form is used as a key, most importantly the credential.id used for the stored-credential lookup ( step 4): without canonicality, one credential has many aliases, and any dedupe, rate-limit, or audit keyed on the string can be split across them. Requiring canonical base64url makes string identity coincide with byte identity.

Example

The base64url subject_token decodes to this assertion bundle (the fixture the reference tests mint):

{
  "version": 1,
  "credential": {
    "id": "PfYagP...JOSjA",
    "rawId": "PfYagP...JOSjA",
    "type": "public-key",
    "response": {
      "clientDataJSON": "eyJ0eXBlIjoid2ViYXV0aG4uZ2V0Iiwi...",
      "authenticatorData": "SZYN5YgOjGh0NBcPZHZgW4_krrmihjLHmVzzuoMdl2MdAAAAAA",
      "signature": "MEUCIQ...Dq8Q",
      "userHandle": "b3BhcXVlLXJhbmRvbS1oYW5kbGU"
    }
  }
}

The token exchange profile

Re-authentication is an OAuth 2.0 Token Exchange [[RFC8693]] at the OP's token endpoint (located via OpenID Connect discovery), with:

The grant MUST require a valid DPoP proof: an OpenID Provider MUST reject an exchange request with no DPoP proof or an invalid one (invalid_dpop_proof / use_dpop_nonce, [[RFC9449]] §12.2; ). This profile issues only sender-constrained tokens; there is no Bearer variant.

The authenticated client_id — established by dereferencing the Client ID Document per [[SOLID-OIDC]] — is the input to all per-client checks in .

The issued token

On success the OpenID Provider MUST mint an ordinary Solid-OIDC access token bound to the exchange request's DPoP public key (a cnf claim whose jkt member is the JWK SHA-256 thumbprint of that key, [[RFC9449]] §6.1), whose webid claim is the WebID resolved in , and return a token-endpoint response with:

Where the deployment issues JWT access tokens (as the reference deployments do), the token SHOULD follow the JWT access-token profile [[RFC9068]] (typ: "at+jwt"). The shape of the access token is otherwise whatever the OP's Solid-OIDC deployment already issues from its interactive flow — this grant changes how the user is authenticated, never what is issued.

The published Solid-OIDC report [[SOLID-OIDC]] defines the webid claim on the DPoP-bound ID Token (§8.1) and leaves "the definition of a global access token" out of scope; deployed Solid servers (Community Solid Server and derivatives) accept a DPoP-bound [[RFC9068]] access token carrying a webid claim. This profile follows deployed practice, and will track Solid-OIDC as its access-token story lands ().

Client requirements

Downgrade refusal. An Application MUST reject a token-exchange response whose token_type is not DPoP (compared case-insensitively, [[RFC6749]] §5.1) and MUST NOT use the returned token. A Bearer token accepted here would subsequently be sent with an Authorization: DPoP scheme — a downgrade that silently drops the sender-constraint this profile exists to provide.

Key continuity. The Application MUST present resource requests with DPoP proofs signed by the same key pair whose proof accompanied the token-exchange request — the key the issued token is bound to (cnf.jkt). Per [[RFC9449]] §7 the resource proof carries the ath claim (the base64url SHA-256 hash of the access token). A fresh, non-extractable key pair SHOULD be generated per session; the key MUST NOT be persisted in extractable form.

An Application SHOULD handle a use_dpop_nonce challenge from the token endpoint by retrying the exchange once with the server-supplied nonce, per [[RFC9449]] §8. Each retry MUST reuse the same key pair; the assertion bundle itself MUST NOT be re-sent after a successful consumption of its challenge (the challenge is single-use, so a replayed bundle fails; a retry after a use_dpop_nonce error is safe because the OP MUST NOT consume the challenge on a request rejected for a missing or stale DPoP nonce — see ).

OpenID Provider verification requirements

The OpenID Provider MUST verify the relayed assertion in the following order. DPoP-proof validation ([[RFC9449]] §4.3), including any nonce demand, precedes the sequence below — a request failing DPoP MUST NOT consume the challenge (so a use_dpop_nonce retry is possible, req-client-nonce). The challenge MUST then be consumed before any cryptographic check, so that a presented assertion is never replayable even when a later step fails:

  1. Decode. Decode the subject_token into an assertion bundle, enforcing the fail-closed structural and canonicality rules of and . A malformed or unsupported-version bundle is invalid_request.
  2. Embedding. Reject the assertion unless clientDataJSON.crossOrigin is false or absent and no topOrigin is present, so an assertion produced inside a cross-origin iframe is never accepted.
  3. Single-use challenge. Atomically consume the challenge named in clientDataJSON.challenge (); reject if it is unknown, expired, or already consumed.
  4. Resolve the credential and WebID. Look up the stored credential record by ⟨authenticated client_id, credentialId⟩ and read its WebID — the OP does not know the user up front; the assertion proves who they are. Reject if no record exists, or if the record is bound to a different client_id.
  5. Cryptographic verification per [[WEBAUTHN-3]] §7.2 against the stored COSE credential public key, asserting: the expected challenge; clientDataJSON.origin ∈ the allowed-origin set (req-origin-check); rpIdHash == SHA-256(per-client RP ID) (req-rpid-per-client); User Verification present — the OP MUST reject an assertion whose UV flag is unset; a valid signature over authenticatorData ‖ SHA-256(clientDataJSON); and a signature counter strictly greater than the stored counter where the authenticator reports a non-zero one.
  6. Advance state. On success, persist the new signCount so a later replay at the same count is rejected. Where the authenticator reports a non-zero counter that did not advance, the OP SHOULD record a clone-suspicion anomaly. A signCount of 0 throughout is the normal synced-passkey case and is not an anomaly.

On success the OP mints the token per . The OP MUST trust no part of the bundle that is not covered by the WebAuthn signature; the embedding pre-check (step 2) operates on the unverified parse only as a defence-in-depth fast path, since the same bytes are re-authenticated by the signature in step 5.

The challenge lifecycle

A challenge issued by an OpenID Provider MUST be short-lived and single-use. The OP MUST maintain a consumed-challenge record so that the first consumption of a challenge succeeds and every subsequent attempt fails. Consumption MUST be an atomic check-and-consume at the OP deployment boundary: a single-node OP MAY use an in-memory store; a clustered or multi-process OP MUST use a store shared across instances so replays cannot race between instances.

Where the issuing endpoint authenticates the ⟨WebID, client_id⟩ context — the registration ceremony () — the OP MUST bind the challenge to that context at issue and verify the binding on consume, so a challenge minted for one account/application cannot be redeemed for another. The assertion-options challenge () is necessarily issued unbound (no context is authenticated at issue); single-use consumption plus the per-client checks at verification are the controls on that path.

A self-contained signed challenge removes session affinity but not the replay record: a signed challenge alone is replayable until expiry. The consumed-challenge record is required either way ([[SOLID-WEBAUTHN-ARCH]] §8.3).

Credential lifecycle and revocation

An OpenID Provider MUST allow the account owner to list and delete registered credential records over an authenticated account route (in the reference implementation, GET/POST /.account/account/<id>/webauthn/credentials). The listing MUST return non-secret metadata only and MUST NOT return the stored COSE public key. Deleting a credential MUST cause subsequent assertions for that credentialId to fail.

Revocation does not retroactively invalidate already-issued access tokens; the short token lifetime of this flow bounds the post-revocation gap. An OP SHOULD keep this flow's access-token lifetime short, and MAY additionally support OAuth token revocation [[RFC7009]] for immediacy. Recovery from a lost authenticator is the normal Solid-OIDC login followed by re-registration: the passkey is a re-authentication accelerator, never the sole factor.

Resource Server trust

This specification adds no requirement to the Resource Server. The RS↔OP trust relationship is the existing Solid-OIDC solid:oidcIssuer link: the RS accepts the issued DPoP-bound access token because its issuer is authoritative for the WebID — the WebID Profile document contains a matching ?webid solid:oidcIssuer ?iss statement ([[SOLID-OIDC]] §6.1) — exactly as for a token obtained by the interactive flow. A Resource Server MUST NOT need any WebAuthn awareness.

Error responses

Failures map to OAuth token-endpoint errors [[RFC6749]] [[RFC8693]] [[RFC9449]]. To deny an enumeration oracle, every assertion-validation failure — unknown credential, origin not allowed, bad signature, signCount regression, UV absent, embedded context, per-client RP-ID mismatch, client-binding mismatch, and challenge unknown/expired/replayed — MUST be returned as a single coarse wire error: invalid_grant with one generic description. The precise reason MUST NOT be exposed on the wire; it MAY be recorded in an audit log ().

Only request-shape errors that are not assertion validation keep a distinct code:

ConditionWire error
Malformed or unsupported-version assertion bundle, including any canonical-base64url failure () invalid_request
Missing or empty subject_token; unsupported subject_token_type invalid_request
DPoP proof absent or invalid; nonce required invalid_dpop_proof / use_dpop_nonce ([[RFC9449]] §12.2)
Any assertion-validation failure (req-coarse-error) invalid_grant

Relationship to RFC 8693 §2.2.2. RFC 8693 directs that when a subject_token is "invalid for any reason" the error MUST be invalid_request. This profile deliberately distinguishes a token that is malformed as a request artefact (invalid_request — re-encode and resend) from an assertion that is well-formed but fails validation as a grant (invalid_grant — re-run the ceremony with a fresh challenge), matching RFC 6749 §5.2's invalid_grant semantics and giving clients an actionable recovery signal while keeping the validation-failure reason coarse. This divergence is flagged for Community Group review (); the reference implementations implement the table above.

The registration, assertion-options, and token endpoints SHOULD be rate-limited; an over-limit request SHOULD be answered with HTTP 429.

Security considerations

This section maps the threat model to the normative requirements that mitigate each threat; the full STRIDE / LINDDUN / attack-tree treatment is in [[SOLID-WEBAUTHN-ARCH]] §9, which this section references rather than restates. The five headline threats below are the reference implementation's threat model ([[REAUTH-IMPL]] DESIGN.md §4); three further considerations follow.

Phishing and application impersonation

The user agent binds the assertion's RP ID and clientDataJSON.origin to the calling page's origin, and neither can be forged by script. The OP-side mitigations are the per-client RP ID derivation (req-rpid-per-client), the allowed-origin set derived from the authenticated client_id (req-origin-set), and the normalised membership check — never a string compare of an origin against a client_id (req-origin-check). An assertion produced for a look-alike origin, or relayed claiming a different application's origin, fails verification. The OP is the trust anchor: the client relays and cannot self-attest, so a malicious or buggy client library weakens nothing.

Token downgrade and confused-deputy

Issuance is DPoP-only (req-dpop-required, req-idp-verified); the client refuses a non-DPoP response (req-client-downgrade) so a Bearer token is never carried under an Authorization: DPoP scheme; and one key pair spans the token-endpoint proof and every resource proof (req-single-keypair), preserving [[RFC9449]] sender-constraint continuity end to end. Theft of the issued token alone is useless without the private key.

Untrusted input on the verifier surface

The subject_token is attacker-supplied bytes hitting the OP before any authentication of the user. The mitigations are the fail-closed structural decode (req-fail-closed) — every field the verifier later reads is presence- and type-checked before any cryptography, so a malformed credential is a clean invalid_request, never a deeper exception — and the canonical-base64url requirement (req-canonical-b64u), which closes the string-aliasing / malleability hazard on string-keyed credential lookups, dedupe, and audit.

Credential handling during registration

Registration rides an already-authenticated session and never handles the login credential: the client relays JSON between the authenticator and the OP's authenticated endpoints. The OP binds the registration challenge to the authenticated ⟨WebID, client_id⟩ (req-challenge-bound), requires the declared clientId to equal the authenticated one, verifies origin and RP ID exactly as at assertion time, and rejects embedded contexts (). Discoverable, user-verified credentials are forced at creation (req-resident-key, req-uv-reg) so the assertion path's assumptions hold. Any non-2xx or malformed step aborts with no partial state.

Replay, freshness, and revocation are OP-side

Replay protection is the single-use, short-TTL, atomically-consumed challenge (, req-challenge-consume) — consumed before cryptographic verification so a presented assertion is dead even when a later step fails — plus the strictly-increasing signCount where reported (req-signcount). Revocation is the credential-record deletion of , bounded by short token lifetimes. These controls are deliberately and exclusively the OP's: the client is stateless (a fresh key pair and a fresh ceremony per upgrade; nothing cached to steal), and a conforming client MUST NOT be assumed to provide any replay or freshness control.

Account and credential enumeration

The coarse wire error (req-coarse-error) prevents a caller from using the token-endpoint response to distinguish which validation check failed — in particular whether a guessed credentialId exists. Audit logs MAY record the precise reason, with identifiers hash-truncated ().

Server-side request forgery (SSRF)

The client_id is attacker-supplied and dereferenced server-side. An OP that fetches Client ID Documents MUST do so with a hardened fetcher: HTTPS only; no private, loopback, or link-local destinations, validated at connect time (after DNS resolution) to close DNS-rebinding; a response body-size cap; no automatic redirect following; and a timeout. Credentials MUST never appear in fetched URLs or logs.

Embedding (iframe)

The OP requires crossOrigin == false (or absent) and rejects any topOrigin (req-no-embed), so an embedding parent cannot harvest a victim application's assertion. The defined re-authentication ceremony runs in the application's own top-level page, so this costs nothing. A future profile MAY accept crossOrigin == true against an explicit topOrigin allow-list ().

Assurance and residual risks

Requiring User Verification (req-uv, req-uv-reg) makes the ceremony multi-factor: the target is AAL2, and AAL3 where the authenticator is device-bound [[SP-800-63B]]. Two residual risks are accepted and documented: synced-passkey clone detection via signCount is weak (synced passkeys commonly report signCount = 0), mitigated by UV; and a malicious application the user logs into can register its own passkey — but that credential is origin-bound to that application and usable by no other, equivalent to the user having logged that application in ([[SOLID-WEBAUTHN-ARCH]] §9.5–§9.6).

Privacy considerations

This section is non-normative except where it restates requirements defined above.

Registrations

This section records the identifiers this specification defines, in the style of the IANA OAuth registries. No IANA registration is made for this version: the identifiers live under urn:solid:, and [[RFC8693]] §3 permits any URI as a token-type identifier ("Other URIs MAY be used to indicate other token types"). If the mechanism generalises beyond Solid, a future profile MAY register an urn:ietf:params:oauth:token-type:webauthn-assertion in the IANA "OAuth URI" registry via a thin IETF Internet-Draft ().

Token type identifier

URNurn:solid:token-type:webauthn-assertion
Common nameSolid WebAuthn assertion bundle
Used assubject_token_type in an [[RFC8693]] token-exchange request (this profile does not use it as an issued_token_type, requested_token_type, or actor_token_type)
Token syntaxUnpadded base64url of a UTF-8 JSON assertion bundle ()
Change controllerThis specification (W3C Solid Community Group, on adoption)
Reference

Grant type (profiled, not defined)

This specification defines no new grant type. It profiles the existing urn:ietf:params:oauth:grant-type:token-exchange ([[RFC8693]] §2.1): an OP conforming to this specification recognises the profile by the subject_token_type above and applies to it. Token-exchange requests with other subject_token_type values are outside this specification.

Assertion-bundle version registry

VersionStatusReference
1Current

Future versions are assigned by this specification's change controller. An OP rejects unknown versions (req-fail-closed); a deprecated version gets a documented sunset window.

Implementation status

This section is non-normative.

Two reference implementations exist, jointly authored (Samu Lang, Jesse Wright), and this specification was written from them (implementation-first):

RequirementStatus
Per-client RP ID, allowed-origin set, normalised origin check () Implemented (OP + shared helpers)
Registration options + verification, opaque user handle () Implemented (OP; client-side helper in [[REAUTH-IMPL]])
Assertion-options endpoint, discoverable ceremony () Implemented (OP)
CORS discipline (req-cors) Partially delegated — the [[CSS-WEBAUTHN]] routes rely on the Community Solid Server's global CORS middleware (origin-reflecting), which satisfies the assertion-options MAY but not the registration-endpoint per-client MUST; an OP-side restriction is proposed
Assertion bundle v1 + fail-closed structural decode () Implemented (both)
Canonical base64url (round-trip) rejection () Implemented in the [[REAUTH-IMPL]] protocol layer; proposed for the [[CSS-WEBAUTHN]] verifier (its codec predates the rule — adoption pending)
DPoP-required exchange, DPoP-bound issued token, coarse errors (, , ) Implemented (OP), except as below
issued_token_type = …token-type:access_token in the success response () Proposed correction — the [[CSS-WEBAUTHN]] grant currently returns the subject token's type (urn:solid:token-type:webauthn-assertion) as issued_token_type; per [[RFC8693]] §2.2.1 that parameter identifies the issued token, so this specification requires urn:ietf:params:oauth:token-type:access_token and the implementation needs a one-line fix
Client downgrade refusal, key continuity, nonce retry () Implemented (client; unit-tested)
DPoP-failure-precedes-challenge-consumption ordering ( preamble) Proposed — implied by the reference client's nonce retry; the ordering guarantee is made explicit here and needs an OP-side conformance test
Challenge lifecycle (atomic single-use, registration binding) () Implemented (OP; concurrent-replay tested)
Credential list/delete route () Implemented (OP)
SSRF-hardened Client-ID-Document fetch () Implemented (OP, HardenedFetcher)

Open issues

This section is non-normative. The following are open for Community Group discussion and may change before any final report:

Acknowledgements

The protocol was designed and implemented jointly by Samu Lang and Jesse Wright in the jeswr/solid-webauthn monorepo (architecture, ADR log, Bikeshed draft, and the CSS OP component) and the jeswr/solid-webauthn-reauth client library. This document ports that normative content to a standalone Community Group-track editor's draft, adds the canonicality, client-conformance, and registration sections, and records the divergences for review.