Solid-OIDC mandates DPoP ([[RFC9449]]) sender-constrained access tokens, which cost the
resource server one asymmetric signature verification on every request. This profile,
DPoP-SK, defines a negotiated, optional fast path that preserves
proof-of-possession while amortizing the asymmetric operation: the client presents one
ordinary DPoP proof to a session establishment endpoint; both parties obtain a
short-lived symmetric session key — either derived from TLS Exported Keying Material
([[RFC8446]] Section 7.5, [[RFC5705]]) so that no key material crosses the wire and the key is
useless on any other TLS connection, or (for browser clients, which cannot access TLS
exporters) generated by the server and delivered once under TLS. Subsequent requests carry an
HTTP Message Signature ([[RFC9421]]) using the hmac-sha256 algorithm over a fixed
covered-component set, with an [[RFC4303]]-style sliding-window counter for replay protection.
DPoP remains the mandatory, always-accepted baseline: DPoP-SK is advertised through OAuth 2.0
Protected Resource Metadata ([[RFC9728]]), is invisible to clients that do not opt in, and
never weakens what the server will accept.
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 design-first, implementation-pending: it graduates
Sections 4–6 of the reviewed design proposal [[HTP-POP]] into normative text, after an
adversarial review pass recorded in . There are no
implementations yet (); the reference implementation is
planned in solid-server-rs.
Drafting disclosure: this draft was prepared with AI assistance (Claude Fable 5, Anthropic), working from the cited design proposal and the primary sources referenced throughout, and is awaiting review by its human editor. Errors introduced in drafting are the tooling's, not the cited sources'.
A Solid resource server verifying DPoP performs, per authenticated request, one asymmetric
signature verification over a fresh proof JWT: RFC 9449 requires the server to check, among
other things, that "the DPoP HTTP request header field value is a single and well-formed JWT"
whose "JWT signature verifies with the public key contained in the jwk JOSE
Header Parameter" ([[RFC9449]] Section 4.3). Because the proof payload
(jti, iat, htu) is new on every request, this
verification cannot be memoized. CPU profiling of a production-shaped Solid server attributes
roughly half of the authenticated-path CPU to exactly this per-request ECDSA verification once
access-token verification is cached ([[SSRS-BENCH]]; analysis in [[HTP-POP]] Section 1).
Yet the property being re-proven — possession of the private key bound into the access token's
cnf.jkt confirmation ([[RFC9449]] Section 6.1) — is constant across a client's
whole session. DPoP-SK proves it once, with a full-strength DPoP proof, and then
re-attests it per request with one HMAC-SHA256 and an O(1) replay-window check.
Solid-OIDC requires DPoP: "the Client MUST send a DPoP proof JWT" when requesting tokens ([[SOLID-OIDC]] Section 8.1), and on the resource server "A DPoP Proof that is valid according to DPoP Internet-Draft, Section 4.3, MUST be present" when a DPoP-bound token is used ([[SOLID-OIDC]] Section 9.3). This profile does not alter, replace, or relax that mandate. Concretely:
Where this profile deliberately deviates from RFC 9449 mechanics — presenting the access
token under the DPoP authorization scheme without a DPoP
proof header field, the proof being replaced by the negotiated message signature — the
deviation is valid only inside an established DPoP-SK session and is flagged inline.
JLWS ([[JLWS-CORE]]) discovers its authorization surface through the same [[RFC9728]]
protected resource metadata this profile negotiates through, so DPoP-SK composes with a
JLWS realm without any new mechanism: the pop_session member
() appears in the realm's metadata document beside JLWS's
jlws_storage_description member ([[JLWS-CORE]] Authorization server
discovery), and the token whose presentation this profile accelerates is the
realm-audienced access token JLWS obtains by [[RFC8693]] token exchange — its single
aud value is the challenge's realm, and the storage server's
validation accepts it only for resources that audience logically contains
([[JLWS-CORE]] Validation by the storage server, step 3). A DPoP-SK session
therefore spans exactly the resources of one JLWS realm, and the per-request
attestation re-proves possession for requests inside that audience. Two scoping notes:
cnf, and a JLWS server already refuses any cnf-bound
token presented bare ([[JLWS-CORE]] Validation by the storage server,
step 5). Bearer tokens without cnf on a Bearer-baseline realm are
outside this profile's scope — for a non-opting client, a JLWS server is observed
unchanged, exactly as this profile already promises for Solid servers.dpop_bound_access_tokens_required: true ([[RFC9728]] Section 2); that
single member covers DPoP-SK too, since every DPoP-SK session is established from a
DPoP-bound access token (). This profile deliberately
mints no separate required-member; DPoP-SK availability is signalled by
pop_session's presence alone ().
No normative change to establishment, key derivation, attestation, anti-replay,
lifetime, or downgrade rules results from this composition: they transfer verbatim.
The composition record, including where the JLWS-side conformance vectors home, is
docs/lws-alignment.md.
K)session_id)keyid parameter.cb)tls-exporter (the key is derived from TLS Exported Keying Material and
the session is pinned to the TLS connection) or none (the key is
server-generated and TLS-delivered; the session is not connection-bound).hmac-sha256 message signature, tagged
dpop-sk, that replaces the per-request DPoP proof inside a session.ath, jktath is "a base64url encoding of the SHA-256 hash of the
ASCII encoding of the associated access token's value" (Section 4.2); jkt is
"the base64url encoding of the JWK SHA-256 Thumbprint (according to [[RFC7638]]) of the DPoP
public key" (Section 6.1).Conformance classes: a DPoP-SK resource server (RS) and a DPoP-SK client. Requirements on "the server" and "the client" below bind those classes respectively.
Client Resource Server
| |
| (0) GET /.well-known/oauth-protected-resource | RFC 9728 discovery
|<------------------------------------------------------| pop_session member
| |
| (1) POST {establishment endpoint} | full RFC 9449 §4.3
| Authorization: DPoP <token> + DPoP: <proof> | verification — once
| { "cb": "tls-exporter" | "none" } |
|<-- 201 { session_id, cb, alg, expires_in, |
| key? (cb=none) / confirm? (cb=tls-exporter) }|
| |
| both sides now hold K (32 bytes) |
| |
| (2..n) GET /resource | per request:
| Authorization: DPoP <token> (no DPoP hdr) | 1 session lookup
| Signature-Input: sig=(...);keyid=...;nonce=N; | 1 HMAC-SHA256
| tag="dpop-sk" | 1 O(1) window check
| Signature: sig=:...: |
|<-- 200 ... or 401 WWW-Authenticate: DPoP (fall back) |
The establishment request is verified with the server's unmodified DPoP machinery, so establishment is exactly as strong as today's per-request check. Any failure at any later point returns the standard DPoP challenge, and the client either re-establishes or falls back to plain per-request DPoP — both full-strength proof-of-possession mechanisms.
The server advertises DPoP-SK in its OAuth 2.0 Protected Resource Metadata document
([[RFC9728]], well-known path /.well-known/oauth-protected-resource) using the
extension member pop_session. RFC 9728 requires that "any metadata parameters
that are not understood MUST be ignored" (Section 3.2), so the member is invisible to
non-adopting clients.
{
"resource": "https://pod.example",
"authorization_servers": ["https://idp.example"],
"dpop_bound_access_tokens_required": true,
"dpop_signing_alg_values_supported": ["ES256", "EdDSA"],
"pop_session": {
"endpoint": "https://pod.example/.pop/session",
"algs": ["hmac-sha256"],
"channel_bindings": ["tls-exporter", "none"],
"profile": "https://w3id.org/jeswr/dpop-sk/v1"
}
}
endpoint (REQUIRED)https scheme
and SHOULD be same-origin with resource.algs (REQUIRED)hmac-sha256
([[RFC9421]] Section 3.3.3).channel_bindings (REQUIRED)tls-exporter and/or
none. A server MUST NOT advertise tls-exporter on any deployment
where it does not terminate TLS in the same process that evaluates requests (a
TLS-terminating proxy makes the exporter unavailable and the binding unsound).profile (REQUIRED)https://w3id.org/jeswr/dpop-sk/v1, identifying this version of this
profile. Clients MUST ignore a pop_session member whose profile
they do not recognise.
A server MAY additionally direct clients to the metadata document from any 401 challenge using
the resource_metadata parameter of WWW-Authenticate
([[RFC9728]] Section 5.1).
A client MUST NOT attempt establishment unless the metadata offers an algorithm and a channel binding flavour it supports. Negotiation is exact-match and fail-closed; the downgrade rules are consolidated in .
POST /.pop/session HTTP/2
Host: pod.example
Authorization: DPoP <access-token>
DPoP: <proof>
Content-Type: application/json
{ "cb": "tls-exporter" }
htm/htu matching this endpoint, the iat acceptance
window, jti replay tracking, ath equal to the hash of the
presented access token, and the cnf.jkt binding), plus every access-token and
WebID/issuer check it applies to any other request ([[SOLID-OIDC]] Section 9). A session
MUST NOT be created if any check fails.cb is the single channel binding flavour the client
requires for this session — it is a demand, not a menu. If the server cannot
satisfy it exactly it MUST refuse with 400 and MUST NOT substitute another flavour
().cb=tls-exporter the server MUST additionally verify that the request
arrived on a TLS 1.3 connection terminated by the server process itself, and MUST reject
establishment over TLS < 1.3 (see ) or received
as 0-RTT early data (see ).
HTTP/2 201
Content-Type: application/json
Cache-Control: no-store
{
"session_id": "AAECAwQFBgcICQoLDA0ODw",
"cb": "tls-exporter",
"alg": "hmac-sha256",
"expires_in": 300,
"confirm": "gvocwuudE9yiXXU5jINFTekorDybyEj_tQWPljW8eRA"
}
HTTP/2 201
Content-Type: application/json
Cache-Control: no-store
{
"session_id": "AAECAwQFBgcICQoLDA0ODw",
"cb": "none",
"alg": "hmac-sha256",
"expires_in": 300,
"key": "EBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8"
}
session_id (REQUIRED)K is what authenticates), but servers MUST NOT reuse one.cb (REQUIRED)alg (REQUIRED)hmac-sha256.expires_in (REQUIRED)key (REQUIRED when cb=none; MUST be absent otherwise)confirm (REQUIRED when cb=tls-exporter; MUST be absent
otherwise)K before sending any attested request:
confirm = base64url(HMAC-SHA256(K, "dpop-sk/v1 confirm" || 0x00 ||
ASCII(session_id))). A client MUST verify it and MUST discard the session on
mismatch. (Publishing an HMAC of a fixed public message under K does not
weaken HMAC-SHA256.)
The response MUST carry Cache-Control: no-store.
cb=none (browser flavour)
The server generates K as 32 CSPRNG bytes and returns it in the
TLS-protected response body, once. No derivation is performed. This flavour exists because
browser JavaScript has no access to TLS exporters (fetch/XHR expose nothing below the HTTP
layer); its weaker binding is analysed honestly in .
cb=tls-exporter (connection-bound flavour)Both ends compute, on the TLS 1.3 connection carrying the establishment exchange:
EKM = TLS-Exporter("EXPERIMENTAL-dpop-sk-v1", context = "" (zero-length), 32 bytes)
PRK = HKDF-Extract(salt = ASCII(session_id), IKM = EKM)
K = HKDF-Expand(PRK,
info = ASCII("dpop-sk/v1") || 0x00 || ASCII(ath) || 0x00 || ASCII(jkt),
L = 32)
TLS-Exporter is the [[RFC8446]] Section 7.5 exporter interface (label
semantics per [[RFC5705]] Section 4). Implementations MUST use the
exporter_master_secret; RFC 8446 states "Implementations MUST use the
exporter_master_secret unless explicitly specified by the application",
and this profile does not specify otherwise — the early exporter MUST NOT be used
().EXPERIMENTAL-dpop-sk-v1. [[RFC5705]] Section 4: "Label
values beginning with 'EXPERIMENTAL' MAY be used for private use without registration";
all other labels require Specification Required registration. Should this profile be
adopted on a standards track, a registered EXPORTER--prefixed label would
replace it (a breaking, versioned change). This profile deliberately does NOT reuse the
[[RFC9266]] EXPORTER-Channel-Binding value: RFC 9266 registers that value
as not secret and its Security Considerations state "The derived data MUST NOT be used
for any purpose other than channel bindings … implementations MUST NOT use channel
binding as a secret key to protect privileged information" — using it as HKDF input
keying material would violate that MUST NOT (see ).session_id member value, and
the info string binds the profile version, the establishment proof's ath,
and the token's cnf.jkt (both as their base64url ASCII strings; the 0x00
separator cannot occur in base64url, so the encoding is injective).K is bound to this
TLS connection: a resumed connection is a new connection with new secrets, so its
exporter output differs and the session dies with the connection, fail-closed
().The server stores, per session, at minimum:
session_id → {
K, // the session key
token_hash, // SHA-256 of the ASCII access token (the ath preimage check)
jkt, // the token's cnf.jkt at establishment
verified_identity, // the WebID / verified-token context injected downstream
token_exp, expires_at, // both enforced per request
replay_window, // high-water mark + bitmap (see §8)
cb, conn_id? // conn_id pinned iff cb=tls-exporter
}
The store MUST be capacity-bounded (eviction is safe: an evicted session simply forces one extra full DPoP round). Sessions are server-local soft state; losing all of them on restart is correct behaviour.
GET /alice/private/doc HTTP/2
Host: pod.example
Authorization: DPoP <access-token>
Signature-Input: sig=("@method" "@target-uri" "authorization");\
created=1720000000;keyid="AAECAwQFBgcICQoLDA0ODw";\
alg="hmac-sha256";nonce="1";tag="dpop-sk"
Signature: sig=:hPkuPd32xbb4hUjR/hjbj0Cp445ZfWoOgrcq8+f3I3g=:
(Header shown folded for display; on the wire each field is one line.)
DPoP authorization scheme
([[RFC9449]] Section 7.1). Inside an established session the client MUST NOT send a
DPoP proof header field with attested requests; the attestation replaces it.
This is a deliberate, negotiated deviation from RFC 9449's requirement that a proof
accompany the scheme — it is only valid where this profile was negotiated, and a server
that does not implement this profile will simply reject such a request with its normal
DPoP challenge, which is the intended fallback signal.("@method" "@target-uri" "authorization") — the derived components
@method and @target-uri ([[RFC9421]] Sections 2.2.1–2.2.2)
plus the Authorization field (binding the exact access token into every
attestation). Clients MAY cover additional components (e.g.
content-digest for requests with content, per [[RFC9530]]); servers MUST
verify whatever set was declared.created (REQUIRED, Integer),
keyid (REQUIRED, the session identifier), nonce
(REQUIRED, the counter — — as its decimal ASCII
representation in the String-typed parameter), tag (REQUIRED, the exact
value dpop-sk), and alg (OPTIONAL; if present it MUST equal the
session's algorithm). expires MAY be included.Signature field value
is an [[RFC8941]] Byte Sequence (standard base64 between colons).
On receiving a request bearing a signature whose tag is dpop-sk,
the server MUST process it under this profile exclusively — it MUST NOT fall back to
evaluating a DPoP header also present on the same request, and MUST NOT treat
the access token as bearer under any outcome. Verification, in order, all fail-closed:
dpop-sk-tagged signature, its
Signature-Input parses as a strict [[RFC8941]] Dictionary of Inner Lists,
the covered components include the REQUIRED set above, and the REQUIRED parameters are
present with the correct structured-field types. Unknown covered-component parameters
fail per [[RFC9421]] Section 2.5.keyid resolves to a live, unexpired
session. A miss or expired session ⇒ challenge (step E below).cb=tls-exporter sessions only): the
request arrived on the session's pinned TLS connection, as 1-RTT data
().token_hash, and the token is unexpired. (This is the
ath-equivalent: a session cannot be ridden with a different token.)created is within the server's acceptance
window — servers SHOULD apply the same window they apply to DPoP iat.@method and @target-uri are derived server-side
from the request itself, which is strictly stronger than DPoP's client-asserted
htm/htu compare — together with the received
Signature-Input serialization; recompute HMAC-SHA256 under the session's
K; compare in constant time. This step MUST complete successfully before any
session-state mutation.nonce, atomically.
(E) Failure signalling. Any failure of steps 1–7 MUST yield
401 with the server's standard WWW-Authenticate: DPoP challenge
([[RFC9449]] Section 7), optionally carrying resource_metadata
([[RFC9728]] Section 5.1). A DPoP-SK client receiving a 401 on an attested request MUST
treat the session as dead and either re-establish or fall back to per-request DPoP; servers
MUST NOT emit, and clients MUST NOT require, any DPoP-SK-specific error code — the generic
challenge keeps the failure path identical to plain DPoP and free of oracle detail.
The nonce parameter carries a per-session counter: the client MUST start at
1 and assign strictly increasing values — never reusing one — and SHOULD
increment by exactly 1 (gaps are tolerated but waste window space). Values MUST fit in an
unsigned 64-bit integer, and a client that could exhaust the space MUST re-establish first
(at 264−1 values this is theoretical). Servers MUST reject non-numeric, zero, or
>64-bit values at parse time.
The server maintains, per session, the receive window of [[RFC4303]] Section 3.4.3: a
high-water mark H (the highest authenticated counter value — RFC 4303's
"right edge of the window represents the highest, validated Sequence Number") and a bitmap
covering exactly the W values H − W + 1 … H (the window includes
H itself). W MUST be at least 128 and SHOULD be 1024.
(RFC 4303's own minimum of 32 and default of 64 target IPsec; HTTP/2 multiplexing routinely
completes more concurrent requests out of order, which is precisely what the window — as
opposed to a strictly-increasing rule, which would spuriously reject valid in-flight
requests — exists to tolerate.)
For a received counter value n (after the signature verified — see below):
n > H: advance the window (shift the bitmap by n − H, set
H = n), mark n. Accept.H − W + 1 ≤ n ≤ H: if the bitmap bit for n is set, reject
(duplicate); otherwise mark it and accept.n ≤ H − W: reject (below the window).Verify-then-mark is normative. Mirroring [[RFC4303]] Section 3.4.3 — "the receive window is updated only if the integrity verification succeeds" — the window MUST NOT be consulted-and-mutated, advanced, or shifted for any request whose HMAC has not verified. This ordering means a forged request can neither burn a counter value nor advance the window (a state-DoS on the session). The signature-verify + window check-and-mark pair for one session MUST be atomic with respect to concurrent requests (a per-session lock or equivalent compare-and-swap), so two concurrent copies of the same counter value cannot both pass.
expires_in MUST NOT exceed the remaining lifetime
of the bound access token, and SHOULD NOT exceed 300 seconds. The bound is the revocation
story: token or signing-key revocation propagates within one session window, because
re-establishment re-runs the full verifier against current issuer state. Servers MUST
enforce both the session expiry and the token expiry on every request.DELETE to the session establishment
endpoint, attested under the session itself (the keyid names the session
to delete; the REQUIRED covered set applies). The server MUST verify the attestation like
any other request before deleting, and respond 204. Server-side eviction, LRU
pressure, and restarts are always-safe alternatives.K
to durable storage.The consolidated fail-closed rules:
Signature/Signature-Input
fields from an attested request yields a request with no PoP at all, which is rejected.)dpop-sk-tagged signature MUST be processed as plain DPoP, always, on every
server. Falling back from DPoP-SK to DPoP is not a security downgrade — both are
full-strength PoP; the fast path is a performance property, not a security ceiling.dpop-sk-tagged signature is processed under this
profile exclusively (): an invalid attestation is a
401, even if a valid DPoP header is also present. This removes any incentive to corrupt an
attestation in transit hoping for laxer processing, and removes ambiguity about which
mechanism authenticated a request.cb differs from its request. A client that
requires tls-exporter can therefore never be silently degraded to
none.alg parameter on the request
([[RFC9421]] Section 7.3.4's key/algorithm-mixup guidance), and MUST reject a mismatching
alg parameter.tls-exporter across
any TLS-terminating intermediary, and the flavour REQUIRES TLS 1.3
().
A stolen access token remains unusable, exactly as under DPoP: every accepted request
requires possession of K (or of the DPoP private key, via the baseline path).
The token is additionally bound into each attestation twice — the authorization
field is a covered component, and the server compares the presented token's hash to the
session's token_hash — so neither a different token on a stolen session nor a
stolen token on a different session verifies.
Within a session, the counter window () rejects duplicates and
below-window stragglers; created bounds pre-dated batches. Across sessions,
keys differ (HKDF binds session_id, ath, jkt; the
cb=none key is fresh CSPRNG output). Across connections, the
tls-exporter flavour's key is derived from connection-unique secrets and the
session is connection-pinned, so a recorded attestation is unverifiable elsewhere; for the
cb=none flavour cross-connection replay of a captured request is
prevented by the counter window (the capture point would have to be inside TLS anyway —
see ). Establishment requests are ordinary DPoP proofs and inherit
jti replay protection.
All exchanges run over TLS; the profile's guarantees are additive to, not a replacement
for, transport security. Because @method and @target-uri are
derived by the server from the actual request, an attacker cannot make a valid attestation
cover a different target than the one the server evaluates — there is no
client-asserted-vs-actual gap to exploit. Deployments where the target URI must be
reconstructed (e.g. behind a trusted reverse proxy, cb=none only) MUST derive
it from configuration or authenticated forwarding information, never from unauthenticated
client-controlled headers.
Compromise of K permits request forgery against this server, for this token,
until the session expires (≤ the token's remaining life, SHOULD ≤ 300 s). [[RFC9421]]
Section 7.3 requires immediate invalidation on symmetric-key compromise: the server-side
remedy is session deletion ().
The two flavours differ honestly here. Under tls-exporter, K
never crosses the wire, is not extractable from the TLS layer by application-level code on
a remote attacker's behalf, and is useless off its connection. Under none,
K crosses the wire once (TLS-protected) and lives in the browser: an XSS
attacker resident in the page can use the session while resident — precisely as it
can use a resident DPoP key, a bound the OAuth browser-based-apps guidance accepts for DPoP
itself — and a key exfiltrated at the establishment instant (before
non-extractable import) is usable off-device against this server, for this token, for the
session TTL. Plain DPoP with a non-extractable key does not have that instant; DPoP with an
extractable key (common in practice) is strictly worse. This is the profile's one genuine
security delta versus baseline DPoP; it is bounded, disclosed, and opt-in — a client with a
stricter threat model does not negotiate cb=none.
The exporter output is a pseudorandom secret derived from the connection's
exporter_master_secret ([[RFC8446]] Section 7.5), known only to the two TLS
endpoints and unique per connection. Deriving K from it yields the
anti-cross-connection-replay property by construction and removes the one wire transfer of
key material. This profile mints its own label (EXPERIMENTAL-dpop-sk-v1,
private-use-conformant per [[RFC5705]] Section 4) rather than reusing the [[RFC9266]]
channel-binding value, because RFC 9266 forbids exactly that reuse ("MUST NOT use channel
binding as a secret key") and registers its value as non-secret; a dedicated label also
keeps DPoP-SK inert with respect to any other authentication mechanism's channel binding on
the same connection. TLS 1.3 is REQUIRED for the flavour so that master-secret uniqueness
and the absence of renegotiation hold unconditionally.
TLS 1.3 0-RTT data has "no guarantees of non-replay between connections" ([[RFC8446]] Section 2.3 / Appendix E.5), and the early exporter inherits the same weakness ("The same warnings apply to any use of the early_exporter_master_secret"). Therefore: establishment requests and attested requests MUST be rejected (with the standard challenge) if received as early data, and the early exporter MUST NOT be used in key derivation. Servers that keep early data disabled satisfy this trivially.
A resumed TLS session is a new connection with fresh secrets; its exporter output differs.
A tls-exporter-flavoured session therefore cannot survive resumption: the
connection pin (verification step 3) fails, the client receives the standard challenge, and
re-establishes on the new connection. This is fail-closed by construction and costs one
DPoP round per connection — the amortization model this profile assumes (long-lived
HTTP/2 connections).
The session store is new server state and therefore a resource-exhaustion surface: it MUST be capacity-bounded with eviction, and establishment SHOULD be rate-limited per client at least as strictly as authenticated writes. Each establishment already costs the client a full DPoP-verified round, so amplification is poor for the attacker. The verify-then-mark ordering () prevents unauthenticated traffic from mutating any per-session state, and per-request verification is one hash-map lookup, one HMAC over a request-sized input, and O(1) integer/bit operations — cheaper than the DPoP baseline the server must in any case keep serving.
K and the key response member are credentials: they MUST NOT be
logged, MUST NOT appear in URLs, MUST NOT be persisted durably, and SHOULD be held
non-extractable where the platform allows (WebCrypto). HMAC comparison MUST be
constant-time. session_id MAY appear in logs (it is an identifier, not a
secret), but implementations SHOULD treat the pair (logs containing
session_id, store containing K) as jointly sensitive.
session_id is a per-client identifier visible to the resource server for the
session's lifetime. It is strictly server-scoped, is never shared across origins, and its
correlation power is a subset of what the server already holds (the access token, the WebID,
the DPoP key thumbprint). Session TTLs bound its lifetime; clients wanting unlinkability
across interactions should simply not establish sessions (plain DPoP has the same
per-token linkability). No new information is exposed to any third party.
There are no implementations of this profile as of this draft. This document
deliberately precedes code: the design proposal it graduates ([[HTP-POP]] Section 9) requires
an adversarial review round (recorded in ) before any
implementation. The planned reference implementation is the "Tier 2" work item in
solid-server-rs (an establishment handler, a bounded session store, RFC 9421
base reconstruction and HMAC verification, and a client helper library for the suite's
applications); the throughput motivation is recorded in that repository's committed bench
records [[SSRS-BENCH]] and MUST be re-measured, not assumed, once the implementation exists.
Interoperability claims in this document are therefore claims about cited specifications, not
about deployed behaviour.
A conforming DPoP-SK resource server implements: the metadata advertisement (), the establishment endpoint () for at least one flavour, per-request verification (), the sliding window (), the lifetime rules (), and all rules in . A conforming DPoP-SK client implements discovery, establishment for at least one flavour, attestation construction, the counter discipline, and the client-side rules in .
This unofficial draft requests no registrations. For the record:
EXPERIMENTAL-dpop-sk-v1 is private-use per [[RFC5705]]
Section 4 and would be replaced by a registered EXPORTER- label on standards
adoption.pop_session is an unregistered extension member, legal
under [[RFC9728]] Section 2 ("Additional protected resource metadata parameters MAY also be
used") and ignored by non-implementers per its Section 3.2; standards adoption would
register it (Specification Required, per RFC 9728 Section 8.1).dpop-sk is an application-chosen [[RFC9421]]
tag parameter value; RFC 9421 defines the parameter for exactly this use and
requires no registration of values.
Every computed value below is generated by
tools/compute-examples.mjs
in this document's repository (Node ≥ 20, standard library only), so the example is
execution-verified. The DPoP public key is the one RFC 9449 uses in its own examples; the
script asserts that its computed jkt equals the value RFC 9449 Section 6.1
publishes for that key (0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I).
access token (illustrative; only its ASCII bytes matter):
eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9.eyJpc3MiOiJodHRwczovL2lkcC5leGFt
cGxlIiwic3ViIjoiaHR0cHM6Ly9pZC5leGFtcGxlL2FsaWNlI21lIn0.EXAMPLE-AS-SIGNATURE
ath = base64url(SHA-256(ASCII(access token)))
= WKXpcs5xM2Dyko2M0gv0NC8vjApybvIBdn17lZ7izG0
DPoP public key (the RFC 9449 example key):
{"crv":"P-256","kty":"EC",
"x":"l8tFrhx-34tV3hRICRDY9zCkDlpBhF42UQUfWVAWBFs",
"y":"9VE4jf_Ok_o64zbTTlcuNJajHmt6v9TDVrU0CdvGRDA"}
jkt = 0ZcOCORZNYy-DWpqq30jZyJGHTN0d2HglBV3uiguA4I (matches RFC 9449 §6.1)
POST /.pop/session HTTP/2
Host: pod.example
Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9.eyJpc3MiOi...
DPoP: <RFC 9449 proof: typ=dpop+jwt, alg=ES256, jwk=(key above),
htm=POST, htu=https://pod.example/.pop/session, iat, jti,
ath=WKXpcs5xM2Dyko2M0gv0NC8vjApybvIBdn17lZ7izG0;
ES256 signature elided — any §4.3-valid proof>
Content-Type: application/json
{ "cb": "none" }
HTTP/2 201
Content-Type: application/json
Cache-Control: no-store
{
"session_id": "AAECAwQFBgcICQoLDA0ODw",
"cb": "none",
"alg": "hmac-sha256",
"expires_in": 300,
"key": "EBESExQVFhcYGRobHB0eHyAhIiMkJSYnKCkqKywtLi8"
}
For the cb=tls-exporter flavour, with an example EKM of
QEFCQ0RFRkdISUpLTE1OT1BRUlNUVVZXWFlaW1xdXl8 (base64url; a real value comes
from the TLS stack via
TLS-Exporter("EXPERIMENTAL-dpop-sk-v1", "", 32)) and the same
session_id/ath/jkt, the derivation of
yields:
K = B2D8_PhuOLMzvM9-ND2aVCXoz8ADWeTfhqiZV3yUvD4 (base64url)
confirm = gvocwuudE9yiXXU5jINFTekorDybyEj_tQWPljW8eRA
GET /alice/private/doc HTTP/2
Host: pod.example
Authorization: DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9.eyJpc3MiOi...
Signature-Input: sig=("@method" "@target-uri" "authorization");\
created=1720000000;keyid="AAECAwQFBgcICQoLDA0ODw";\
alg="hmac-sha256";nonce="1";tag="dpop-sk"
Signature: sig=:hPkuPd32xbb4hUjR/hjbj0Cp445ZfWoOgrcq8+f3I3g=:
The signature base ([[RFC9421]] Section 2.5; lines joined with a single LF, no trailing LF):
"@method": GET
"@target-uri": https://pod.example/alice/private/doc
"authorization": DPoP eyJhbGciOiJFUzI1NiIsInR5cCI6ImF0K2p3dCJ9.eyJpc3MiOiJodHRwczovL2lkcC5leGFtcGxlIiwic3ViIjoiaHR0cHM6Ly9pZC5leGFtcGxlL2FsaWNlI21lIn0.EXAMPLE-AS-SIGNATURE
"@signature-params": ("@method" "@target-uri" "authorization");created=1720000000;keyid="AAECAwQFBgcICQoLDA0ODw";alg="hmac-sha256";nonce="1";tag="dpop-sk"
HMAC-SHA256(K = EBES…Li8, base) =
hPkuPd32xbb4hUjR/hjbj0Cp445ZfWoOgrcq8+f3I3g= (standard base64, as an
[[RFC8941]] Byte Sequence). The server, having verified it, marks counter 1 in the window
(H = 1) and serves the request.
Per the graduation condition in [[HTP-POP]] Section 9 ("adversarial review round before any code"), the following attacks were tried against this design during drafting. Each entry records the attack, the outcome, and — where the attack found something — what changed. Entries marked FINDING changed this specification relative to the design proposal.
K ⇒ no attestation; no DPoP key ⇒ no proof; rule
(1) forbids bearer acceptance.session_id, no key. The identifier is
not a capability; verification step 6 fails without K.tls-exporter). The key
derives from connection-unique exporter secrets and the session is connection-pinned
(step 3): unverifiable elsewhere. For cb=none the window still rejects; the
residual off-device-use case is the disclosed browser-flavour delta
().Signature* fields leaves a PoP-less request ⇒ 401. No acceptance path exists
without one of the two PoP mechanisms.tls-exporter request with cb=none: the exact-match rule
((4)) makes the client discard the session. The request-side
"demand, not menu" semantics were tightened from the design proposal's capability field to
close this explicitly.token_hash compare (step 4) and
the authorization field being a covered component.@method/@target-uri are covered and derived server-side from the
actual request; a transplant changes the base and the HMAC fails.K, and an on-path attacker
cannot modify TLS-protected content anyway; the residual (a compromised client's own
traffic) is out of scope, and clients MAY cover content-digest for
defense-in-depth. Documented rather than mandated.K can pre-compute attestations for chosen
counters/targets (the analogue of [[RFC9449]] Section 11.2 pre-generated proofs). Bounded
by created-window enforcement, the session TTL, and the server's
forced-rekey right (); under tls-exporter the batch
additionally dies with the connection.K from the [[RFC9266]] EXPORTER-Channel-Binding value. RFC 9266
registers that value as not secret and its security considerations forbid exactly
this ("MUST NOT use channel binding as a secret key to protect privileged information"),
and additionally restrict a connection's channel binding to one authentication-mechanism
instance. Using it as IKM was therefore spec-nonconforming and fragile against any future
protocol on the same connection disclosing the CB value. Change: a dedicated
private-use exporter label EXPERIMENTAL-dpop-sk-v1
().cb=none). The one
wire transfer of K. TLS-protected, no-store, immediately imported
non-extractable; the residual exfiltration-at-issuance window is the disclosed delta in
— deliberately documented rather than hidden.tls-exporter session across a resumed connection. Fails closed: new exporter
secrets + the connection pin ().alg
parameters the session did not negotiate (cf. [[RFC9421]] Section 7.3.4). The verifier
uses the session's algorithm unconditionally and rejects mismatched alg
((5)); v1 has exactly one algorithm, minimizing the surface.dpop-sk-tagged signature makes processing exclusive
((3)).confirm
member (added relative to the design proposal) detects mismatch before any attested request
and turns it into a clean abort.Divergences from [[HTP-POP]] Section 4, each recorded with rationale in the repository's
DECISIONS.md:
EXPERIMENTAL-dpop-sk-v1 replaces reuse of the
RFC 9266 channel-binding value ( — a genuine finding against the
design basis).tls-exporter flavour.cb is an exact requirement, not a capability
offer ().confirm key-confirmation member is REQUIRED in
tls-exporter responses ().DELETE) is specified.https://w3id.org/jeswr/dpop-sk/v1 (the design sketched
…/pop-session/v1 for a three-part umbrella document; this document is the
DPoP-SK part standing alone).