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 proofhtm— HTTP methodhtu— HTTP target URIiat— proof creation timeath— 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:
Validates the access token.
Reads
cnf.jkt.Reads the public JWK from the DPoP proof.
Calculates its JWK thumbprint.
Compares the result with
cnf.jkt.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
typisdpop+jwtSupported asymmetric algorithm
Public JWK exists
Public JWK does not contain private key material
JWT signature
htmhtuiatjtiathcnf.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
jtiInvalid
jtiInvalid
htmInvalid
htuExpired or invalid
iatInvalid
athReplayed proof
cnf.jktmismatchMissing 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.
