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'.

Introduction

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.

Relationship to DPoP and Solid-OIDC

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.

Relationship to Linked Web Storage (JLWS)

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:

  • JLWS's baseline presentation is Bearer ([[JLWS-CORE]] Token presentation), unlike Solid-OIDC. This profile's never-bare rule is a property of DPoP-bound tokens: the tokens this profile operates on always carry 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.
  • A JLWS realm designated PoP-required signals it with 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.

Terminology

session establishment endpoint
The resource-server endpoint, discovered via metadata (), at which a client exchanges one DPoP-authenticated request for a DPoP-SK session.
session key (K)
The 32-byte symmetric key shared between one client keypair/token pair and one resource server for the lifetime of one DPoP-SK session.
session identifier (session_id)
An opaque, unguessable identifier the server mints for the session; carried per request as the [[RFC9421]] keyid parameter.
channel binding flavour (cb)
Either 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).
attestation
The per-request [[RFC9421]] hmac-sha256 message signature, tagged dpop-sk, that replaces the per-request DPoP proof inside a session.
ath, jkt
As in [[RFC9449]]: ath 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.

Overview

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.

Discovery and negotiation

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)
The session establishment endpoint URL. It MUST use the https scheme and SHOULD be same-origin with resource.
algs (REQUIRED)
Attestation algorithms offered, as names from the [[RFC9421]] HTTP Signature Algorithms registry. This version of the profile defines exactly one: hmac-sha256 ([[RFC9421]] Section 3.3.3).
channel_bindings (REQUIRED)
The channel binding flavours offered: 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)
The URI 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 .

Session establishment

Request

POST /.pop/session HTTP/2
Host: pod.example
Authorization: DPoP <access-token>
DPoP: <proof>
Content-Type: application/json

{ "cb": "tls-exporter" }
    

Response

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)
Opaque; MUST contain at least 128 bits of CSPRNG output, encoded base64url without padding. It is a key identifier, not a secret capability on its own (possession of K is what authenticates), but servers MUST NOT reuse one.
cb (REQUIRED)
Echoes the flavour in force. It MUST equal the requested flavour; a client MUST abort (discard the session, use plain DPoP) if it differs.
alg (REQUIRED)
The attestation algorithm in force for this session; in this version always hmac-sha256.
expires_in (REQUIRED)
Session lifetime in seconds; constraints in .
key (REQUIRED when cb=none; MUST be absent otherwise)
The 32-byte session key, base64url without padding, generated from a CSPRNG. Browser clients SHOULD import it as a non-extractable WebCrypto HMAC key and discard the raw bytes immediately. Neither party may log or persist it ().
confirm (REQUIRED when cb=tls-exporter; MUST be absent otherwise)
A key-confirmation value letting the client verify that both sides derived the same 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.

Key derivation

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 ().
  • The label is 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 ).
  • The exporter context is explicitly present and zero-length (per [[RFC8446]] Section 7.5, "New uses of exporters SHOULD provide a context in all exporter computations, though the value could be empty"). Session uniqueness is bound in HKDF instead: the salt is the ASCII bytes of the 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).
  • HKDF-Extract/HKDF-Expand are as defined in [[RFC5869]] with SHA-256.
  • No key material crosses the wire in this flavour, and 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 flavour REQUIRES TLS 1.3. Exporter-based constructions over earlier TLS versions are only sound "when the TLS handshake results in unique master secrets", which for TLS < 1.3 requires the extended master secret extension and no renegotiation ([[RFC9266]] Sections 1 and 4). Requiring TLS 1.3 removes that entire class of preconditions.

Server session state

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.

Per-request attestation

Request construction

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.)

Verification

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:

  1. Parse. There is exactly one 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.
  2. Session lookup. keyid resolves to a live, unexpired session. A miss or expired session ⇒ challenge (step E below).
  3. Connection binding (cb=tls-exporter sessions only): the request arrived on the session's pinned TLS connection, as 1-RTT data ().
  4. Token binding. SHA-256 of the ASCII value of the presented access token equals the session's token_hash, and the token is unexpired. (This is the ath-equivalent: a session cannot be ridden with a different token.)
  5. Freshness. created is within the server's acceptance window — servers SHOULD apply the same window they apply to DPoP iat.
  6. Signature. Recompute the signature base from the actual request@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.
  7. Anti-replay. Apply the sliding-window check-and-mark of to nonce, atomically.
  8. Admit. Inject the session's stored verified identity downstream exactly as a successful DPoP verification would; authorization (e.g. WAC) is unchanged.

(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.

Anti-replay: the sliding window

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):

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.

Session lifetime, rotation, revocation, termination

Fallback and no-downgrade rules

The consolidated fail-closed rules:

  1. A DPoP-bound access token is NEVER accepted without a valid proof-of-possession: either a valid RFC 9449 proof or a valid DPoP-SK attestation. There is no bearer fallback under any failure mode. (Consequence: stripping the Signature/Signature-Input fields from an attested request yields a request with no PoP at all, which is rejected.)
  2. DPoP-SK never displaces DPoP: a request carrying a valid DPoP proof and no 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.
  3. A request carrying a 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.
  4. Channel-binding negotiation is exact-match, both ways: the server MUST NOT create a session under a different flavour than requested (400 instead), and the client MUST discard a session whose response cb differs from its request. A client that requires tls-exporter can therefore never be silently degraded to none.
  5. The attestation algorithm is fixed at establishment. The server MUST verify with the session's algorithm only, regardless of any alg parameter on the request ([[RFC9421]] Section 7.3.4's key/algorithm-mixup guidance), and MUST reject a mismatching alg parameter.
  6. Servers MUST NOT advertise, and clients MUST NOT use, tls-exporter across any TLS-terminating intermediary, and the flavour REQUIRES TLS 1.3 ().

Security considerations

Token theft

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.

Replay

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.

MITM and the transport

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.

Key compromise — and the honest browser-flavour delta

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.

Why the exporter binding, and why a dedicated label

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.

0-RTT early data

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.

TLS resumption

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).

Denial of service and state

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.

Key handling and logging

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.

Privacy considerations

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.

Conformance and implementation status

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 .

Registration considerations

This unofficial draft requests no registrations. For the record:

Worked example

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).

Inputs

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)
    

Establishment

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
    

Attested request (cb=none session, first request)

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.

Adversarial review log

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.

  1. Stolen token, no key. Replaying a leaked access token from another machine. Fails: no K ⇒ no attestation; no DPoP key ⇒ no proof; rule (1) forbids bearer acceptance.
  2. Stolen session_id, no key. The identifier is not a capability; verification step 6 fails without K.
  3. Straight replay of a captured attested request. Duplicate counter ⇒ rejected by the window (bitmap hit) or below-window rule. Capture requires being inside TLS in the first place.
  4. Cross-connection replay (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 ().
  5. Signature stripping (downgrade-to-bearer). Removing Signature* fields leaves a PoP-less request ⇒ 401. No acceptance path exists without one of the two PoP mechanisms.
  6. Channel-binding downgrade at establishment. A server (or an active attacker able to alter the response — which TLS excludes anyway) answering a 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.
  7. Window burn by forged traffic. Sending garbage signatures with high counter values to advance the window past the victim's in-flight requests. Fails: verify-then-mark is normative — unauthenticated requests cannot mutate the window ([[RFC4303]]'s own ordering rule, adopted verbatim).
  8. Concurrent duplicate race. Two copies of the same counter in flight simultaneously. Closed by the normative atomicity requirement on verify+mark ().
  9. Token substitution on a live session. Attested request presenting a different token (e.g. the attacker's own, hoping to inherit the victim's session identity, or vice versa). Fails twice: token_hash compare (step 4) and the authorization field being a covered component.
  10. Attestation transplant onto a different target. @method/@target-uri are covered and derived server-side from the actual request; a transplant changes the base and the HMAC fails.
  11. Body swap under a valid signature. The minimum covered set does not include content. Inside the threat model this is a non-attack — producing a valid attestation for the swapped-body request requires 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.
  12. Pre-generated attestation batches. An attacker with transient access to 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.
  13. Establishment flooding / state exhaustion. Each attempt costs the attacker a full valid DPoP round (poor amplification); the store is bounded with safe eviction; rate limiting is normative ().
  14. FINDING — exporter-value reuse. The design proposal derived 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 ().
  15. Key exposure at issuance (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.
  16. 0-RTT replay. Attested or establishment requests in early data would be connection-level replayable ([[RFC8446]] Appendix E.5) and the early exporter is transplantable under PSK compromise (Appendix E.5.1). Both are excluded normatively ().
  17. Resumption confusion. Attempting to ride a tls-exporter session across a resumed connection. Fails closed: new exporter secrets + the connection pin ().
  18. Timing oracle on verification. Constant-time comparison is normative; the failure response is the same generic DPoP challenge for every failure class (), deliberately withholding which check failed.
  19. Algorithm/key-mixup games. Sending 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.
  20. Dual-mechanism ambiguity. Sending both a DPoP proof and a (bad) attestation hoping the server picks the passing one. Closed: a dpop-sk-tagged signature makes processing exclusive ((3)).
  21. Counter exhaustion / overflow. 64-bit bound with parse-time rejection of oversized values; wrap is unreachable in practice and re-establishment is normative before exhaustion ().
  22. Derivation-mismatch downgrade probing. A client silently deriving a wrong key (e.g. an intermediary changed the connection) would fail its first request and might be tempted into repeated re-establishment loops; the confirm member (added relative to the design proposal) detects mismatch before any attested request and turns it into a clean abort.

Changes relative to the design proposal

Divergences from [[HTP-POP]] Section 4, each recorded with rationale in the repository's DECISIONS.md:

  1. Dedicated exporter label EXPERIMENTAL-dpop-sk-v1 replaces reuse of the RFC 9266 channel-binding value ( — a genuine finding against the design basis).
  2. TLS 1.3 is REQUIRED for the tls-exporter flavour.
  3. The establishment request's cb is an exact requirement, not a capability offer ().
  4. A confirm key-confirmation member is REQUIRED in tls-exporter responses ().
  5. Explicit session termination (DELETE) is specified.
  6. The HKDF info string is fully specified byte-for-byte (versioned label, 0x00 separators, ASCII encodings) rather than sketched.
  7. The profile URI is 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).