dpop_flutter

An HTTP-client-agnostic implementation of OAuth 2.0 DPoP (RFC 9449 - Demonstrating Proof of Possession) for Dart and Flutter.

This package only produces Authorization/DPoP headers - it never sends a request itself. Use it with package:http via dpop_flutter_http, with Dio via dpop_flutter_dio, or attach the headers to any other HTTP client manually.

What problem does DPoP solve?

A normal OAuth bearer token is just a string:

Flutter --Access Token--> API

Anyone who obtains that string - via a leaked log, a misconfigured proxy, a compromised dependency - can replay it as if they were the legitimate client:

Attacker --stolen access token--> API

DPoP fixes this by binding the token to a private key that never leaves the device. Every request additionally carries a short-lived, signed proof that only the holder of that key could have produced:

Flutter
   ├── Access Token
   └── Private Key --signs--> DPoP Proof
                                   │
Access Token + DPoP Proof ────────┴──> API

An attacker who steals only the access token (not the private key) cannot produce a valid proof, so a DPoP-enforcing server rejects the replayed request:

Stolen Access Token + No Private Key = Rejected

Installation

dependencies:
  dpop_flutter: ^0.1.0

Quick start

import 'package:dpop_flutter/dpop_flutter.dart';

final dpop = DpopClient(keyStore: SecureDpopKeyStore());
await dpop.initialize();

final headers = await dpop.createHeaders(
  method: 'GET',
  uri: Uri.parse('https://api.example.com/profile'),
  accessToken: accessToken,
);
// headers == {'Authorization': 'DPoP <accessToken>', 'DPoP': '<proof-jwt>'}

Send those headers with whichever HTTP client you already use:

package:http

final response = await http.get(uri, headers: await dpop.createHeaders(
  method: 'GET',
  uri: uri,
  accessToken: accessToken,
));

Or use dpop_flutter_http's DpopHttpClient for a drop-in http.Client that attaches headers (and handles nonce retries) automatically.

Dio

Use dpop_flutter_dio's DpopInterceptor:

dio.interceptors.add(DpopInterceptor(dpop: dpop, dio: dio, tokenProvider: tokenProvider));

Custom HTTP client

Any client that lets you set headers can use this package - it doesn't care:

final headers = await dpop.createHeaders(method: request.method, uri: request.uri, accessToken: token);
request.headers.addAll(headers);

Token refresh

dpop_flutter never manages login, refresh, or session state. You pass the current access token into createHeaders (directly, or via an AccessTokenProvider when using an adapter) every time - if your app refreshes the token, the next createHeaders call simply receives the new value. There is nothing to invalidate or resynchronize on the DPoP side.

Nonce

Some servers require a per-origin nonce, delivered via a DPoP-Nonce response header (RFC 9449 §8). DpopClient tracks this automatically through its DpopNonceStore (in-memory and origin-scoped by default): call

await dpop.handleNonceChallenge(uri, response.headers['dpop-nonce']!);

after a 401 that carries a DPoP-Nonce header, and the next createHeaders call for that origin will include it. Both dpop_flutter_http and dpop_flutter_dio do this automatically, including retrying the failed request exactly once with a fresh proof.

Key rotation

await dpop.rotateKey();

Rotation is never automatic. Rotating replaces the key pair immediately, which means any access token bound to the old key (via cnf.jkt on the server) will no longer produce valid proofs. Only rotate when your application is prepared to re-authenticate or re-bind - for example, right after a full re-login, not as a background maintenance task.

Multiple accounts

Give each signed-in account its own key by scoping SecureDpopKeyStore's storage key:

final dpopForAccountA = DpopClient(keyStore: SecureDpopKeyStore(storageKey: 'dpop_key_$accountAId'));
final dpopForAccountB = DpopClient(keyStore: SecureDpopKeyStore(storageKey: 'dpop_key_$accountBId'));

Security

See doc/security.md in this repository for the full threat model, cryptography and dependency rationale, and what is explicitly out of scope (DPoP does not replace HTTPS, OAuth, or backend authorization).

Failure handling

Every error this package raises is a DpopException subtype (DpopKeyException, DpopProofException, DpopNonceException, DpopConfigurationException, DpopStorageException, DpopCryptoException, DpopValidationException) with a log-safe message - never a key, token, or full proof. Network-level failures (timeouts, DNS, 5xx) are left to your HTTP client's own error types; this package does not wrap them.

ASP.NET Core

See doc/aspnetcore_interop.md for what a resource server needs to check to validate proofs produced by this package, including an illustrative ASP.NET Core middleware sketch.

FAQ

Does DPoP replace HTTPS? No - always use HTTPS. DPoP protects against token replay, not network eavesdropping.

Does DPoP replace OAuth? No - it's an enhancement to OAuth 2.0 access tokens (and, via dpop_jkt, authorization codes), not a replacement for the authorization flow itself.

What happens if the access token is stolen? Without the matching private key, a thief cannot produce a valid proof, so a DPoP-enforcing server rejects the replayed token.

What happens if the private key is stolen? Then the attacker can produce valid proofs. Platform secure storage (Keychain/Keystore/DPAPI/ libsecret) makes extracting the key significantly harder, but DPoP does not protect against a fully compromised device - see doc/security.md.

What happens after app reinstall? A new key pair is generated; the app looks, to the authorization server, like a new device. This is expected.

Does DPoP work with Dio? Yes, via dpop_flutter_dio - it is not required by the core.

Does DPoP work with package:http? Yes, via dpop_flutter_http - also not required by the core.

Can I use another HTTP client? Yes - createHeaders() returns a plain Map<String, String> that works with any client.

Does the backend need Redis? No requirement from this package - your resource server needs some short-lived store for recently-seen jti values to detect replay, and for nonces if you use them; an in-memory cache is enough for a single-instance API, a distributed cache for multi-instance deployments.

Does DPoP protect against a compromised device? No - see the "What is out of scope" section of doc/security.md.

Contributing

Issues and PRs are welcome - see CONTRIBUTING.md in the repo for setup, workflow, and what a PR should include, and CODE_OF_CONDUCT.md. Found a security issue? Please don't open a public issue - see SECURITY.md instead.

Libraries

dpop_flutter
HTTP-client-agnostic OAuth 2.0 DPoP (RFC 9449) for Dart and Flutter.
testing
Test doubles for dpop_flutter and its adapters.