Phase 3: Securing OAuth Access Tokens with DPoP in ASP.NET Core

Introduction

In Phase 1, I built a self-contained Identity Provider demonstrating OAuth 2.1 Authorization Code flow, PKCE, JWT access tokens, RSA signing, and JWK-based public key discovery.

In Phase 2, I extended the solution with a protected Identity & Trusted Data API acting as an OAuth Resource Server. The API validates JWT access tokens, verifies scopes, and retrieves fictional identity data from PostgreSQL.

Phase 3 takes the security model one step further by introducing DPoP — Demonstrating Proof of Possession.

Instead of treating an access token as a simple bearer credential, DPoP binds the token to a client's cryptographic key. The client must prove possession of the corresponding private key when making requests to the protected API.

This provides a stronger sender-constrained token model and demonstrates an important OAuth security pattern used in modern identity architectures.


What is DPoP?

DPoP, or Demonstrating Proof of Possession, is an OAuth mechanism defined by RFC 9449 that sender-constrains access and refresh tokens using public-key cryptography.

With a traditional bearer token:

Authorization: Bearer <access-token>

whoever possesses the token can potentially present it to the API.

With DPoP, the client also sends a cryptographic proof:

Authorization: DPoP <access-token>
DPoP: <dpop-proof-jwt>

The API verifies that the client presenting the access token also possesses the private key to which that token was bound.

DPoP itself is not an authentication or authorization mechanism. It provides an additional proof-of-possession layer around the OAuth token.


Architecture

The Phase 3 architecture builds directly on the previous phases:

                    ┌─────────────────────────┐
                    │   Client Application    │
                    │                         │
                    │  EC P-256 Key Pair      │
                    │  Private Key            │
                    │  Public JWK             │
                    └────────────┬────────────┘
                                 │
                                 │ Authorization Code
                                 │ + PKCE
                                 │ + DPoP Proof
                                 ▼
                    ┌─────────────────────────┐
                    │  IdentityProvider.Api   │
                    │                         │
                    │ OAuth Authorization     │
                    │ Server                  │
                    │                         │
                    │ JWT + cnf.jkt           │
                    └────────────┬────────────┘
                                 │
                                 │ DPoP-bound JWT
                                 ▼
                    ┌─────────────────────────┐
                    │    IdentityData.Api     │
                    │                         │
                    │ OAuth Resource Server   │
                    │                         │
                    │ JWT Validation          │
                    │ DPoP Validation         │
                    │ Scope Validation        │
                    └────────────┬────────────┘
                                 │
                                 ▼
                    ┌─────────────────────────┐
                    │ PostgreSQL / Supabase   │
                    │                         │
                    │ Users                   │
                    │ Identity Attributes     │
                    │ Consents                │
                    │ Audit Logs              │
                    └─────────────────────────┘

The important change from Phase 2 is the addition of the cryptographic proof layer between the client and protected API.


DPoP Key Pair

The client generates an asymmetric key pair.

For this POC, the implementation uses:

Algorithm: ES256
Curve:     P-256

The private key remains with the client.

The public key is represented as a JWK and can be included in the DPoP proof.

Conceptually:

Client
│
├── Private Key
│     └── Used to sign DPoP proofs
│
└── Public JWK
      └── Included in DPoP proof

The private key is never sent to the Identity Provider or Resource Server.


DPoP Proof JWT

Every protected request receives its own DPoP proof.

A simplified proof contains:

{
  "typ": "dpop+jwt",
  "alg": "ES256",
  "jwk": {
    "kty": "EC",
    "crv": "P-256",
    "x": "...",
    "y": "..."
  }
}

The JWT payload contains request-specific information:

{
  "jti": "unique-proof-id",
  "htm": "GET",
  "htu": "https://localhost:7002/api/profile",
  "iat": 1750000000,
  "ath": "..."
}

The important claims are:

  • jti — unique identifier for the proof

  • htm — HTTP method

  • htu — HTTP target URI

  • iat — proof creation time

  • ath — hash of the access token when the proof accompanies an access token

The DPoP specification requires each HTTP request to use a unique proof.


Binding the Access Token to the Key

The key feature of this phase is binding the access token to the client's public key.

The Identity Provider calculates a JWK SHA-256 thumbprint of the public key.

That thumbprint is represented using:

{
  "cnf": {
    "jkt": "public-key-thumbprint"
  }
}

The resulting JWT is therefore associated with a specific public key.

During resource access, the Resource Server:

  1. Validates the access token.

  2. Reads cnf.jkt.

  3. Reads the public JWK from the DPoP proof.

  4. Calculates its JWK thumbprint.

  5. Compares the result with cnf.jkt.

  6. Rejects the request if they do not match.

This means possession of the access token alone is not sufficient.


Protected API Request

A successful request to the Phase 2 API now looks conceptually like:

GET /api/profile HTTP/1.1
Host: localhost:7002
Authorization: DPoP <access-token>
DPoP: <fresh-dpop-proof>

The Resource Server validates both pieces.

                 Request
                    │
                    ▼
          ┌───────────────────┐
          │ Validate JWT      │
          └─────────┬─────────┘
                    │
                    ▼
          ┌───────────────────┐
          │ Validate DPoP     │
          │ Proof Signature   │
          └─────────┬─────────┘
                    │
                    ▼
          ┌───────────────────┐
          │ Validate htm/htu  │
          │ iat/jti/ath       │
          └─────────┬─────────┘
                    │
                    ▼
          ┌───────────────────┐
          │ Validate cnf.jkt  │
          └─────────┬─────────┘
                    │
                    ▼
          ┌───────────────────┐
          │ Validate Scope    │
          │ identity.read     │
          └─────────┬─────────┘
                    │
                    ▼
               API Response

Replay Protection

One of the important DPoP security considerations is proof replay.

The jti claim provides a unique identifier that can be used for replay detection.

For this POC, I introduced an abstraction:

IDpopReplayStore

The initial implementation uses an in-memory store.

This keeps the Phase 3 implementation simple while allowing the component to be replaced later with a distributed implementation such as Redis.

The API can therefore reject a previously-used DPoP proof instead of accepting the same proof repeatedly.


Authentication and Authorization Pipeline

An important architectural decision was keeping DPoP validation outside of the CQRS query handlers.

The request pipeline is responsible for security.

Conceptually:

HTTP Request
     │
     ▼
Authentication
     │
     ├── JWT validation
     ├── DPoP validation
     ├── Key binding validation
     └── Replay protection
     │
     ▼
Authorization
     │
     └── Scope validation
     │
     ▼
CQRS Query
     │
     ▼
Application Logic
     │
     ▼
Database

This keeps security concerns separate from application business logic.

For example, the GetProfileQueryHandler does not need to understand how DPoP works.

It receives an already-authenticated request context and focuses on retrieving the requested data.


CQRS Integration

Phase 3 continues the CQRS architecture introduced in the previous phases.

The security pipeline handles:

  • JWT validation

  • DPoP proof validation

  • cryptographic verification

  • token binding

  • replay detection

  • scope validation

CQRS handlers handle:

  • retrieving identity data

  • querying users

  • retrieving identity attributes

  • application-level business logic

This separation makes the security implementation reusable across multiple protected endpoints.


API Endpoints

The existing protected endpoints remain available:

GET /api/profile
GET /api/identity

Both require:

identity.read

The difference is that Phase 3 requires DPoP authentication rather than simply accepting a bearer token.

A DPoP-bound token presented as:

Authorization: Bearer <token>

should not be treated as a valid DPoP request.


Security Validation

The Resource Server validates several pieces of information before allowing access.

JWT validation

The API verifies:

  • Signature

  • Issuer

  • Audience

  • Expiration

  • Token type

  • Required claims

DPoP validation

The API verifies:

  • DPoP header exists

  • JWT structure is valid

  • typ is dpop+jwt

  • Supported asymmetric algorithm

  • Public JWK exists

  • Public JWK does not contain private key material

  • JWT signature

  • htm

  • htu

  • iat

  • jti

  • ath

  • cnf.jkt

Authorization

Finally, the API verifies:

identity.read

If the token is missing or invalid, the API returns an authentication failure.

If authentication succeeds but the required scope is missing, the API returns an authorization failure.


Testing Strategy

Security functionality is heavily test-driven in this phase.

The test suite covers scenarios such as:

Valid requests

  • Valid JWT

  • Valid DPoP proof

  • Correct HTTP method

  • Correct URI

  • Correct access-token hash

  • Correct JWK thumbprint

  • Required OAuth scope

Invalid requests

  • Missing DPoP header

  • Invalid DPoP JWT

  • Invalid signature

  • Unsupported algorithm

  • Missing jti

  • Invalid jti

  • Invalid htm

  • Invalid htu

  • Expired or invalid iat

  • Invalid ath

  • Replayed proof

  • cnf.jkt mismatch

  • Missing scope

  • Bearer token used against DPoP-protected endpoint

End-to-end testing

The most important test demonstrates the complete flow:

OAuth Authorization Code
        +
       PKCE
        +
    DPoP Key Pair
        │
        ▼
Authorization Server
        │
        ▼
DPoP-bound JWT
        │
        ▼
Protected Resource
        │
        ▼
JWT + DPoP validation
        │
        ▼
Identity Data

Technology Stack

Backend

  • C#

  • .NET 10

  • ASP.NET Core Web API

  • CQRS

  • MediatR

  • Entity Framework Core

Security

  • OAuth 2.1 concepts

  • Authorization Code flow

  • PKCE

  • JWT

  • JWK

  • DPoP

  • ES256

  • ECDSA P-256

  • JWK SHA-256 Thumbprints

Data

  • PostgreSQL

  • Supabase

  • Entity Framework Core

Testing

  • xUnit

  • ASP.NET Core integration testing

Development

  • Swagger / OpenAPI

  • Docker

  • Git

  • GitHub


Project Structure

The project continues using the same repository:

secure-identity-data-poc/
│
├── src/
│   ├── IdentityProvider.Api/
│   │   └── Phase 1
│   │
│   └── IdentityData.Api/
│       └── Phase 2 + Phase 3
│
├── tests/
│   ├── IdentityProvider.UnitTests/
│   ├── IdentityProvider.IntegrationTests/
│   ├── IdentityData.UnitTests/
│   └── IdentityData.IntegrationTests/
│
├── docs/
│   ├── phase-1.md
│   ├── phase-2.md
│   └── phase-3.md
│
└── SecureIdentityData.sln

Keeping the phases in one repository demonstrates how the architecture evolves rather than presenting three unrelated projects.


Running the POC Locally

The APIs can be run locally using the ASP.NET Core development environment.

Typical development endpoints include:

Identity Provider

https://localhost:7001/swagger

Identity Data API

https://localhost:7002/swagger

Swagger can be used to inspect the APIs and manually test protected endpoints.

The Phase 3 implementation can also be tested using tools such as PowerShell or an API client capable of generating the required DPoP proofs.


What I Learned

The biggest lesson from this phase was that protecting an API is not only about validating a JWT.

A traditional bearer token answers:

"Do you have the token?"

A sender-constrained token adds another question:

"Can you prove possession of the key that this token is bound to?"

That additional cryptographic relationship changes the security model significantly.

This phase also reinforced the importance of separating:

Authentication
        ↓
Proof of Possession
        ↓
Authorization
        ↓
Application Logic

rather than placing security logic directly inside business or CQRS handlers.


Phase Roadmap

This project is being developed incrementally.

Phase 1 — Identity Provider

OAuth Authorization Code + PKCE + JWT + JWK

Phase 2 — Protected Identity API

JWT validation + OAuth scopes + PostgreSQL + CQRS

Phase 3 — DPoP Security

Sender-constrained access tokens + ES256 + proof validation + replay protection

Phase 4 — Web Client

Next.js / React client integration

Phase 5 — Cloud Deployment

AWS deployment, cloud infrastructure, observability, and production-oriented architecture


Source Code

The complete source code for this phase is available in the project repository:

GitHub: [VIEW SOURCE CODE]

The repository contains the implementation, tests, documentation, and the previous phases.


Disclaimer

This is an independent technical proof of concept created for demonstrating OAuth, JWT, PKCE, JWK, DPoP, API security, and cloud architecture concepts.

It does not connect to or integrate with Singpass, MyInfo, or any production identity service.

The identity data used by the application is fictional and exists only for demonstration and development purposes.

Related Posts

FlappyLove — Real-Time Multiplayer Browser Game ❤️

A playful real-time multiplayer browser game built with Next.js and TypeScript, featuring multiplayer rooms, synchronized gameplay, live scoring, and a Railway-hosted game server. ❤️

September 12, 2026·Marvin D. Verdera·Next.js, React

Phase 4: Building a Secure Next.js Identity Client

A Next.js and React client demonstrating OAuth, PKCE, JWT, and DPoP integration with the Secure Identity & Trusted Data API.

September 11, 2026·Marvin D. Verdera·Next.js, React, PKCE, JWK, JWT, API Security

Phase 2: Building a Protected Identity & Trusted Data API with ASP.NET Core

Phase 2 extends the Secure Identity & Trusted Data API POC with a protected ASP.NET Core Resource Server, JWT/JWK validation, OAuth scopes, CQRS, Entity Framework Core, and PostgreSQL.

September 1, 2026·Marvin D. Verdera·API Security, JWK, JWT, PKCE, Identity Provider, API Integration