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'.
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):
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.
This section is non-normative.
subject_token_type and the verification an OP performs for it
().cnf.jkt).solid:oidcIssuer trust link the Resource Server already
uses. Nothing changes at the Resource Server.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.
This specification defines three conformance classes. The terms are used in the sense of [[SOLID-OIDC]].
navigator.credentials and relays the results to the
OpenID Provider. Conformance requirements: ,
, ,
(the client-side requirements, including
req-client-downgrade).
solid:oidcIssuer link
[[SOLID-OIDC]]. That zero-change property is a design goal; a Resource Server that is
entirely unaware of WebAuthn is conforming.
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).
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 ().
https://app.example/clientid.jsonld [[SOLID-OIDC]]. It identifies the
Application.
subject_token ().
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.
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 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.
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:
challenge, recorded as outstanding per
and bound to the authenticated
⟨WebID, client_id⟩;rp.id set to the per-client RP ID
(req-rpid-per-client);authenticatorSelection.userVerification =
"required";authenticatorSelection.residentKey =
"required" (equivalently requireResidentKey: true).
The only defined re-authentication path is discoverable
( returns an empty allowCredentials), so a
non-resident credential could never be selected at assertion time;excludeCredentials listing the credentials already registered for the
⟨WebID, client_id⟩, so the same authenticator does not register twice.
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.
On the registration request carrying the attestation (the WebAuthn
RegistrationResponseJSON plus the declared clientId), the
OpenID Provider MUST, in order:
clientId equals the
authenticated client_id;clientDataJSON.crossOrigin is false
or absent and no topOrigin is present
(req-no-embed);req-origin-check), the per-client RP ID
(req-rpid-per-client), and User Verification required
(req-uv-reg);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 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:
POST (issuing a single-use challenge is a state-changing,
non-cacheable operation). It MUST reject other methods with HTTP 405
(Allow: POST). An OP MAY additionally expose the challenge as a safe
GET read where its challenge issuance is genuinely side-effect-free.allowCredentials MUST be empty. The authenticator selects a resident passkey,
and the verifier resolves the ⟨WebID, client_id⟩ from the presented
credentialId at the token exchange ().challenge, recorded as outstanding per
. Two calls MUST return distinct challenges.userVerification =
"required".rpId to the host of the request's Origin header;
this value is advisory (the user agent uses it to select the credential), because the
verifier re-derives and enforces the per-client RP ID at the token exchange
(req-rpid-per-client).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 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:
version1. An
OpenID Provider MUST reject a bundle whose version it does not support
().
credentialAuthenticationResponseJSON
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.
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:
credential.id and credential.rawId are present, non-empty
canonical base64url strings;credential.type is exactly "public-key";credential.response is an object whose clientDataJSON,
authenticatorData, and signature members are present, non-empty
canonical base64url strings; andcredential.response.userHandle, when present and non-null, is itself a
non-empty canonical base64url string.
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.
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:
= is outside the unpadded alphabet), or contains any character
outside A–Z a–z 0–9 - _;length mod 4 == 1) can never be produced
by an encoder; orA 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.
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"
}
}
}
Re-authentication is an OAuth 2.0 Token Exchange [[RFC8693]] at the OP's token endpoint (located via OpenID Connect discovery), with:
grant_type = urn:ietf:params:oauth:grant-type:token-exchange
([[RFC8693]] §2.1);subject_token = the encoded assertion bundle
();subject_token_type = urn:solid:token-type:webauthn-assertion;client_id = the Application's Client ID Document URI, sent as a body
parameter (the Application is a public client,
token_endpoint_auth_method: none, per [[SOLID-OIDC]]); andDPoP header of the request
([[RFC9449]] §5).
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 .
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:
token_type = "DPoP" ([[RFC9449]] §5); andissued_token_type = urn:ietf:params:oauth:token-type:access_token
([[RFC8693]] §2.2.1 — issued_token_type is REQUIRED in a successful
token-exchange response and describes the issued token, which here is an
ordinary access token).
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
().
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 ).
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:
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.
clientDataJSON.crossOrigin is false or absent
and no topOrigin is present, so an assertion produced inside
a cross-origin iframe is never accepted.
clientDataJSON.challenge (); reject if it
is unknown, expired, or already consumed.
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.
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.
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.
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).
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.
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.
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:
| Condition | Wire 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.
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.
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.
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.
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.
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 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.
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 ().
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.
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
().
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).
This section is non-normative except where it restates requirements defined above.
client_id⟩ per re-authentication — exactly what it already learns in
the interactive Solid-OIDC flow.
req-user-handle). A deterministic handle such as
SHA-256(WebID) would let any party holding an assertion offline-confirm a
WebID guess — an offline-confirmable cross-application correlator — and is prohibited. The
userHandle relayed inside the assertion bundle therefore reveals nothing about
the WebID to intermediaries.
req-coarse-error).
none is acceptable
(), so the OP does not learn — and is not encouraged to
demand — the authenticator's make and model, avoiding a device-fingerprinting vector.
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 ().
| URN | urn:solid:token-type:webauthn-assertion |
|---|---|
| Common name | Solid WebAuthn assertion bundle |
| Used as | subject_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 syntax | Unpadded base64url of a UTF-8 JSON assertion bundle () |
| Change controller | This specification (W3C Solid Community Group, on adoption) |
| Reference |
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.
| Version | Status | Reference |
|---|---|---|
1 | Current |
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.
This section is non-normative.
Two reference implementations exist, jointly authored (Samu Lang, Jesse Wright), and this specification was written from them (implementation-first):
@jeswr/css-webauthn, in the
jeswr/solid-webauthn monorepo) — the OP side on the Community Solid Server:
registration + per-account user-handle store, passkey store, assertion-options endpoint,
atomic challenge cache, hardened Client-ID-Document fetcher, assertion verifier, the
token-exchange grant, rate limiting, audit logging, and the credentials list/delete
route.@jeswr/solid-webauthn-reauth) — the client
and the shared wire-format layer: registration helper, redirect-free token provider, the
DPoP-bound exchange with downgrade refusal and nonce retry, and the fail-closed
canonical-base64url bundle codec plus origin-normalisation helpers an OP verifier can
reuse.| Requirement | Status |
|---|---|
| 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) |
This section is non-normative. The following are open for Community Group discussion and may change before any final report:
client_id URI origin), and the proof-of-control mechanism that would make it
safe (req-origin-set).crossOrigin == true against an
explicit topOrigin allow-list (req-no-embed).invalid_grant-vs-invalid_request divergence from
[[RFC8693]] §2.2.2 for assertion-validation failures
(note-8693-tension).urn:ietf:params:oauth:token-type:webauthn-assertion and a thin IETF
Internet-Draft if the mechanism generalises beyond Solid
().
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.