PHPackages                             labrodev/laravel-dpop - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Authentication &amp; Authorization](/categories/authentication)
4. /
5. labrodev/laravel-dpop

ActiveLibrary[Authentication &amp; Authorization](/categories/authentication)

labrodev/laravel-dpop
=====================

RFC 9449 DPoP for Laravel — issues EC P-256-bound JWTs and verifies DPoP proofs on protected routes.

v1.0.3(today)02↑2900%MITPHPPHP ^8.4 || ^8.5

Since Mar 23Pushed todayCompare

[ Source](https://github.com/labrodev/laravel-dpop)[ Packagist](https://packagist.org/packages/labrodev/laravel-dpop)[ Docs](https://github.com/labrodev/laravel-dpop)[ RSS](/packages/labrodev-laravel-dpop/feed)WikiDiscussions main Synced today

READMEChangelogDependencies (13)Versions (5)Used By (0)

Laravel DPoP
============

[](#laravel-dpop)

[![Latest Version on Packagist](https://camo.githubusercontent.com/c56f579cf127675c38d1a75b963a56fe86391b2245de56306c32f5b92fdb8720/68747470733a2f2f696d672e736869656c64732e696f2f7061636b61676973742f762f6c6162726f6465762f64706f702e7376673f7374796c653d666c61742d737175617265)](https://packagist.org/packages/labrodev/dpop)[![PHP Version](https://camo.githubusercontent.com/ce8589ed48dfb81f814cd2b56184a4d08c5acadf0ef701dcfa5c50afa9caf818/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f7068702d253545382e342d626c75653f7374796c653d666c61742d737175617265)](https://www.php.net)[![Laravel Version](https://camo.githubusercontent.com/d595a13461fa4fb5ff354efcf503be0ab4b47a805ead05c98e29a1bd428294ae/68747470733a2f2f696d672e736869656c64732e696f2f62616467652f6c61726176656c2d25354531322e302d7265643f7374796c653d666c61742d737175617265)](https://laravel.com)[![License](https://camo.githubusercontent.com/6b7c3263a484633fed1cb18e125e215ffc8d168cb36a5f01b406fb290a937f96/68747470733a2f2f696d672e736869656c64732e696f2f6769746875622f6c6963656e73652f6c6162726f6465762f6c61726176656c2d64706f703f7374796c653d666c61742d737175617265)](LICENSE)

RFC 9449 **Demonstration of Proof-of-Possession (DPoP)** for Laravel. Issues EC P-256-bound JWTs via a built-in token endpoint and verifies DPoP proofs on protected routes via middleware.

---

What is DPoP?
-------------

[](#what-is-dpop)

DPoP ([RFC 9449](https://www.rfc-editor.org/rfc/rfc9449)) is an application-level mechanism for binding access tokens to a client's public key. Each request carries a short-lived, single-use proof-of-possession JWT signed with the client's private key. Even if a bearer token is stolen, it cannot be used without the corresponding private key.

---

Requirements
------------

[](#requirements)

DependencyVersionPHP`^8.4`Laravel`^12.0``firebase/php-jwt``^6.0``web-token/jwt-library``^3.4``spatie/laravel-data``^4.0`---

Installation
------------

[](#installation)

```
composer require labrodev/dpop
```

Run the interactive installer:

```
php artisan dpop:install
```

This writes all `DPOP_*` environment variables to `.env` and publishes the config file.

### Manual installation

[](#manual-installation)

Publish the config:

```
php artisan vendor:publish --provider="Labrodev\Dpop\DpopServiceProvider" --tag="dpop-config"
```

Add the required environment variables to your `.env`:

```
DPOP_JWT_SECRET=your-64-char-secret
DPOP_JWT_ALGORITHM=HS256
DPOP_JWT_LIFETIME=3600
DPOP_CLOCK_SKEW=30
DPOP_PROOF_HEADER=DPoP
DPOP_ALLOWED_ORIGINS=
DPOP_TOKEN_ROUTE=api/dpop/token
DPOP_CACHE_STORE=
DPOP_JTI_TTL=600
```

---

Configuration
-------------

[](#configuration)

After publishing, edit `config/dpop.php`:

```
return [
    'jwt' => [
        'secret'    => env('DPOP_JWT_SECRET'),
        'algorithm' => env('DPOP_JWT_ALGORITHM', 'HS256'),
        'lifetime'  => env('DPOP_JWT_LIFETIME', 3600),
    ],

    // Acceptable clock skew in seconds for DPoP proof iat validation
    'clock_skew' => env('DPOP_CLOCK_SKEW', 30),

    // Cache store for JTI anti-replay and idempotency (null = app default)
    'cache_store' => env('DPOP_CACHE_STORE'),

    // How long a used JTI is retained to detect replays (seconds)
    'jti_ttl' => env('DPOP_JTI_TTL', 600),

    // Header name carrying the DPoP proof (default: DPoP)
    'proof_header' => env('DPOP_PROOF_HEADER', 'DPoP'),

    // Comma-separated list of allowed Origin values (empty = allow all)
    'allowed_origins' => explode(',', env('DPOP_ALLOWED_ORIGINS', '')),

    // Route URI for the token endpoint (null or empty = disabled)
    'token_route' => env('DPOP_TOKEN_ROUTE', 'api/dpop/token'),
];
```

---

Token Endpoint
--------------

[](#token-endpoint)

A `POST` endpoint is registered automatically at the URI defined in `dpop.token_route` (default: `POST /api/dpop/token`).

### Request

[](#request)

```
POST /api/dpop/token
Content-Type: application/json

{
    "jwk": {
        "kty": "EC",
        "crv": "P-256",
        "x": "",
        "y": ""
    },
    "scope": "read write"
}
```

The `jwk` must be an EC P-256 **public** key. Including the private key component `d` will return a `422`.

### Response

[](#response)

```
{
    "data": {
        "type": "token",
        "attributes": {
            "token": "",
            "expires_in": 3600
        }
    }
}
```

The response always includes `Cache-Control: no-store`.

### Issued JWT claims

[](#issued-jwt-claims)

ClaimValue`iss``config('app.url')``sub`JWK thumbprint (RFC 7638)`jkt`JWK thumbprint (RFC 7638)`scp`Array of requested scopes`iat`Issued-at timestamp`exp``iat + dpop.jwt.lifetime`---

Protecting Routes
-----------------

[](#protecting-routes)

Apply the `dpop` middleware to any route or route group:

```
// Single route
Route::get('/api/resource', ResourceController::class)
    ->middleware('dpop');

// With required scopes
Route::post('/api/orders', OrderStoreController::class)
    ->middleware('dpop:write');

// Multiple required scopes (all must be present)
Route::delete('/api/orders/{id}', OrderDeleteController::class)
    ->middleware('dpop:write,admin');

// Route group
Route::middleware('dpop:read')->group(function () {
    Route::get('/api/profile', ProfileController::class);
    Route::get('/api/orders', OrderIndexController::class);
});
```

### Accessing the verified token

[](#accessing-the-verified-token)

After the middleware passes, the decoded JWT payload is available from the request:

```
$jwt = $request->attributes->get('dpop_jwt');
$scopes = $jwt['scp'] ?? [];
$subject = $jwt['sub'];
```

---

Idempotency Middleware
----------------------

[](#idempotency-middleware)

The package ships an optional `dpop.idempotency` middleware for unsafe HTTP methods (POST, PUT, PATCH, DELETE).

```
Route::post('/api/payments', PaymentStoreController::class)
    ->middleware(['dpop', 'dpop.idempotency']);
```

Clients must send an `Idempotency-Key` header (UUID format):

```
POST /api/payments
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
```

ScenarioResponseFirst requestNormal responseReplay with same bodyCached response + `Idempotency-Replayed: true` headerReplay with different body`409 Conflict` + `{"error": "E.I.2"}`Missing / invalid key`422 Unprocessable` + `{"error": "E.I.1"}`---

Error Codes
-----------

[](#error-codes)

All errors return JSON `{"error": ""}` with the appropriate HTTP status.

CodeStepHTTPDescription`D.E.1`1401Missing or non-Bearer Authorization header`D.E.2`2401Invalid JWT signature`D.E.3`3401JWT expired or missing `exp` claim`D.E.4`4401Missing `jkt` claim in JWT`D.E.5`5401Missing DPoP proof header`D.E.6`6401DPoP proof `typ` is not `dpop+jwt``D.E.7`7401DPoP proof `alg` is not `ES256` or key is not EC P-256`D.E.8`8401DPoP proof JWS cryptographic signature invalid`D.E.9`9401`htm` does not match request method`D.E.10`10401`htu` does not match request URL`D.E.11`11401`iat` outside acceptable clock skew`D.E.12`12401`jti` replayed (anti-replay)`D.E.13`13401JWK thumbprint does not match `jkt` claim`D.E.14`—422JWK contains private key `d``C.O.1`—401Origin not in allowed origins list`S.1`—401Required scope not present in token`E.I.1`—422Missing or invalid `Idempotency-Key``E.I.2`—409Idempotency key reused with different request body---

Client Example
--------------

[](#client-example)

A minimal JavaScript client using the Web Crypto API:

```
// Generate an EC P-256 key pair
const keyPair = await crypto.subtle.generateKey(
    { name: 'ECDSA', namedCurve: 'P-256' },
    true,
    ['sign', 'verify'],
);

const publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);

// 1. Obtain a DPoP-bound token
const tokenRes = await fetch('/api/dpop/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    body: JSON.stringify({ jwk: publicJwk, scope: 'read' }),
});
const { data: { attributes: { token } } } = await tokenRes.json();

// 2. Build a DPoP proof for each request
async function buildProof(method, url) {
    const header = { alg: 'ES256', typ: 'dpop+jwt', jwk: publicJwk };
    const payload = { htm: method, htu: url, iat: Math.floor(Date.now() / 1000), jti: crypto.randomUUID() };
    const enc = (obj) => btoa(JSON.stringify(obj)).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
    const data = enc(header) + '.' + enc(payload);
    const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey, new TextEncoder().encode(data));
    const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig))).replace(/\+/g, '-').replace(/\//g, '_').replace(/=/g, '');
    return data + '.' + sigB64;
}

// 3. Make a protected request
const proof = await buildProof('GET', 'https://your-app.com/api/resource');
const res = await fetch('/api/resource', {
    headers: { 'Authorization': `Bearer ${token}`, 'DPoP': proof },
});
```

---

Development
-----------

[](#development)

```
composer install
composer test          # run PHPUnit
composer pint          # fix code style
composer pint:test     # check code style without fixing
composer phpstan       # static analysis (level 8)
composer ci            # test + pint:test + phpstan
```

---

License
-------

[](#license)

MIT — see [LICENSE](LICENSE).

###  Health Score

42

—

FairBetter than 90% of packages

Maintenance100

Actively maintained with recent releases

Popularity3

Limited adoption so far

Community2

Small or concentrated contributor base

Maturity54

Maturing project, gaining track record

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

4

Last Release

0d ago

PHP version history (2 changes)v1.0.0PHP ^8.4

v1.0.1PHP ^8.4 || ^8.5

### Community

Maintainers

![](https://www.gravatar.com/avatar/b29c2afb9bfe2502125a5403436be931d78a8be09fffd77d4225664b1c13d7ed?d=identicon)[labrodev](/maintainers/labrodev)

---

Tags

jwtlaravelAuthenticationlaravel-packagelabrodevdpoprfc9449proof-of-possession

###  Code Quality

TestsPHPUnit

Static AnalysisPHPStan

Code StyleLaravel Pint

### Embed Badge

![Health badge](/badges/labrodev-laravel-dpop/health.svg)

```
[![Health](https://phpackages.com/badges/labrodev-laravel-dpop/health.svg)](https://phpackages.com/packages/labrodev-laravel-dpop)
```

###  Alternatives

[auth0/login

Auth0 Laravel SDK. Straight-forward and tested methods for implementing authentication, and accessing Auth0's Management API endpoints.

2794.9M3](/packages/auth0-login)[benbjurstrom/cognito-jwt-guard

A laravel auth guard for JSON Web Tokens issued by Amazon AWS Cognito

1113.1k](/packages/benbjurstrom-cognito-jwt-guard)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
