Skip to main content

DPoP

WIP

Verifying RFC 9449 proof-of-possession headers, so a token stolen from a client is useless without that client's private key

DPoP binds a token to a key pair the client holds. Each request carries a DPoP header: a short JWT, signed with the client's private key, naming the method and URL. DPoPVerifier checks one.

It is a building block for your own resource server, not a transport: the built-in transports do not bind their tokens to a key. Your token issuer puts the key's thumbprint in the token as cnf.jkt (bindPayloadToDPoP(payload, jkt) does that), and each request's proof must match it.

import { dPoPVerifier } from '@gentleduck/auth/core/transport'
import { redisDPoPNonceStore } from '@gentleduck/auth/adapters/redis'

const dpop = dPoPVerifier({ nonceStore: redisDPoPNonceStore({ redis }) })

const { jkt } = await dpop.verify(request.headers.get('dpop') ?? '', { method: request.method, url: request.url }, accessToken)
if (jkt !== token.cnf.jkt) throw new AuthError('AUTH_DPOP_INVALID', { reason: 'key mismatch' })

verify checks the signature, that the proof's key fits its algorithm, the method and URL, the age of the proof, the ath hash of the access token when one is passed, and that its jti was not seen before. Any failure throws AUTH_DPOP_INVALID.

OptionDefault
acceptedAlgs['ES256', 'EdDSA']; RS256 and PS256 can be added
freshnessMs60 seconds
clockSkewMs30 seconds
nonceStorein memory; use redisDPoPNonceStore or valkeyDPoPNonceStore across instances
expectedNoncenone; a string or a function answering the server nonce the proof must carry

The in-memory jti store only sees its own instance, so across a fleet a proof could be replayed against another one.