{"id":50349421,"url":"https://github.com/labrodev/laravel-dpop","last_synced_at":"2026-05-29T20:30:26.250Z","repository":{"id":346382179,"uuid":"1188989013","full_name":"labrodev/laravel-dpop","owner":"labrodev","description":null,"archived":false,"fork":false,"pushed_at":"2026-03-23T16:32:54.000Z","size":34,"stargazers_count":0,"open_issues_count":0,"forks_count":0,"subscribers_count":0,"default_branch":"main","last_synced_at":"2026-03-24T13:13:10.938Z","etag":null,"topics":[],"latest_commit_sha":null,"homepage":null,"language":"PHP","has_issues":true,"has_wiki":null,"has_pages":null,"mirror_url":null,"source_name":null,"license":"mit","status":null,"scm":"git","pull_requests_enabled":true,"icon_url":"https://github.com/labrodev.png","metadata":{"files":{"readme":"README.md","changelog":"CHANGELOG.md","contributing":null,"funding":null,"license":"LICENSE","code_of_conduct":null,"threat_model":null,"audit":null,"citation":null,"codeowners":null,"security":null,"support":null,"governance":null,"roadmap":null,"authors":null,"dei":null,"publiccode":null,"codemeta":null,"zenodo":null,"notice":null,"maintainers":null,"copyright":null,"agents":null,"dco":null,"cla":null}},"created_at":"2026-03-22T21:07:10.000Z","updated_at":"2026-03-23T16:32:58.000Z","dependencies_parsed_at":null,"dependency_job_id":null,"html_url":"https://github.com/labrodev/laravel-dpop","commit_stats":null,"previous_names":["labrodev/laravel-dpop"],"tags_count":4,"template":false,"template_full_name":null,"purl":"pkg:github/labrodev/laravel-dpop","repository_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/labrodev%2Flaravel-dpop","tags_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/labrodev%2Flaravel-dpop/tags","releases_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/labrodev%2Flaravel-dpop/releases","manifests_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/labrodev%2Flaravel-dpop/manifests","owner_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners/labrodev","download_url":"https://codeload.github.com/labrodev/laravel-dpop/tar.gz/refs/heads/main","sbom_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories/labrodev%2Flaravel-dpop/sbom","scorecard":null,"host":{"name":"GitHub","url":"https://github.com","kind":"github","repositories_count":286080680,"owners_count":33670211,"icon_url":"https://github.com/github.png","version":null,"created_at":"2022-05-30T11:31:42.601Z","updated_at":"2026-05-26T15:22:16.424Z","status":"online","status_checked_at":"2026-05-29T02:00:06.066Z","response_time":107,"last_error":null,"robots_txt_status":"success","robots_txt_updated_at":"2025-07-24T06:49:26.215Z","robots_txt_url":"https://github.com/robots.txt","online":true,"can_crawl_api":true,"host_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub","repositories_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repositories","repository_names_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/repository_names","owners_url":"https://repos.ecosyste.ms/api/v1/hosts/GitHub/owners"}},"keywords":[],"created_at":"2026-05-29T20:30:25.406Z","updated_at":"2026-05-29T20:30:26.235Z","avatar_url":"https://github.com/labrodev.png","language":"PHP","funding_links":[],"categories":[],"sub_categories":[],"readme":"# Laravel DPoP\n\n[![Latest Version on Packagist](https://img.shields.io/packagist/v/labrodev/dpop.svg?style=flat-square)](https://packagist.org/packages/labrodev/dpop)\n[![PHP Version](https://img.shields.io/badge/php-%5E8.4-blue?style=flat-square)](https://www.php.net)\n[![Laravel Version](https://img.shields.io/badge/laravel-%5E12.0-red?style=flat-square)](https://laravel.com)\n[![License](https://img.shields.io/github/license/labrodev/laravel-dpop?style=flat-square)](LICENSE)\n\nRFC 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.\n\n---\n\n## What is DPoP?\n\nDPoP ([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.\n\n---\n\n## Requirements\n\n| Dependency | Version |\n|---|---|\n| PHP | `^8.4` |\n| Laravel | `^12.0` |\n| `firebase/php-jwt` | `^6.0` |\n| `web-token/jwt-library` | `^3.4` |\n| `spatie/laravel-data` | `^4.0` |\n\n---\n\n## Installation\n\n```bash\ncomposer require labrodev/dpop\n```\n\nRun the interactive installer:\n\n```bash\nphp artisan dpop:install\n```\n\nThis writes all `DPOP_*` environment variables to `.env` and publishes the config file.\n\n### Manual installation\n\nPublish the config:\n\n```bash\nphp artisan vendor:publish --provider=\"Labrodev\\Dpop\\DpopServiceProvider\" --tag=\"dpop-config\"\n```\n\nAdd the required environment variables to your `.env`:\n\n```env\nDPOP_JWT_SECRET=your-64-char-secret\nDPOP_JWT_ALGORITHM=HS256\nDPOP_JWT_LIFETIME=3600\nDPOP_CLOCK_SKEW=30\nDPOP_PROOF_HEADER=DPoP\nDPOP_ALLOWED_ORIGINS=\nDPOP_TOKEN_ROUTE=api/dpop/token\nDPOP_CACHE_STORE=\nDPOP_JTI_TTL=600\n```\n\n---\n\n## Configuration\n\nAfter publishing, edit `config/dpop.php`:\n\n```php\nreturn [\n    'jwt' =\u003e [\n        'secret'    =\u003e env('DPOP_JWT_SECRET'),\n        'algorithm' =\u003e env('DPOP_JWT_ALGORITHM', 'HS256'),\n        'lifetime'  =\u003e env('DPOP_JWT_LIFETIME', 3600),\n    ],\n\n    // Acceptable clock skew in seconds for DPoP proof iat validation\n    'clock_skew' =\u003e env('DPOP_CLOCK_SKEW', 30),\n\n    // Cache store for JTI anti-replay and idempotency (null = app default)\n    'cache_store' =\u003e env('DPOP_CACHE_STORE'),\n\n    // How long a used JTI is retained to detect replays (seconds)\n    'jti_ttl' =\u003e env('DPOP_JTI_TTL', 600),\n\n    // Header name carrying the DPoP proof (default: DPoP)\n    'proof_header' =\u003e env('DPOP_PROOF_HEADER', 'DPoP'),\n\n    // Comma-separated list of allowed Origin values (empty = allow all)\n    'allowed_origins' =\u003e explode(',', env('DPOP_ALLOWED_ORIGINS', '')),\n\n    // Route URI for the token endpoint (null or empty = disabled)\n    'token_route' =\u003e env('DPOP_TOKEN_ROUTE', 'api/dpop/token'),\n];\n```\n\n---\n\n## Token Endpoint\n\nA `POST` endpoint is registered automatically at the URI defined in `dpop.token_route` (default: `POST /api/dpop/token`).\n\n### Request\n\n```http\nPOST /api/dpop/token\nContent-Type: application/json\n\n{\n    \"jwk\": {\n        \"kty\": \"EC\",\n        \"crv\": \"P-256\",\n        \"x\": \"\u003cbase64url-encoded x\u003e\",\n        \"y\": \"\u003cbase64url-encoded y\u003e\"\n    },\n    \"scope\": \"read write\"\n}\n```\n\nThe `jwk` must be an EC P-256 **public** key. Including the private key component `d` will return a `422`.\n\n### Response\n\n```json\n{\n    \"data\": {\n        \"type\": \"token\",\n        \"attributes\": {\n            \"token\": \"\u003csigned-jwt\u003e\",\n            \"expires_in\": 3600\n        }\n    }\n}\n```\n\nThe response always includes `Cache-Control: no-store`.\n\n### Issued JWT claims\n\n| Claim | Value |\n|---|---|\n| `iss` | `config('app.url')` |\n| `sub` | JWK thumbprint (RFC 7638) |\n| `jkt` | JWK thumbprint (RFC 7638) |\n| `scp` | Array of requested scopes |\n| `iat` | Issued-at timestamp |\n| `exp` | `iat + dpop.jwt.lifetime` |\n\n---\n\n## Protecting Routes\n\nApply the `dpop` middleware to any route or route group:\n\n```php\n// Single route\nRoute::get('/api/resource', ResourceController::class)\n    -\u003emiddleware('dpop');\n\n// With required scopes\nRoute::post('/api/orders', OrderStoreController::class)\n    -\u003emiddleware('dpop:write');\n\n// Multiple required scopes (all must be present)\nRoute::delete('/api/orders/{id}', OrderDeleteController::class)\n    -\u003emiddleware('dpop:write,admin');\n\n// Route group\nRoute::middleware('dpop:read')-\u003egroup(function () {\n    Route::get('/api/profile', ProfileController::class);\n    Route::get('/api/orders', OrderIndexController::class);\n});\n```\n\n### Accessing the verified token\n\nAfter the middleware passes, the decoded JWT payload is available from the request:\n\n```php\n$jwt = $request-\u003eattributes-\u003eget('dpop_jwt');\n$scopes = $jwt['scp'] ?? [];\n$subject = $jwt['sub'];\n```\n\n---\n\n## Idempotency Middleware\n\nThe package ships an optional `dpop.idempotency` middleware for unsafe HTTP methods (POST, PUT, PATCH, DELETE).\n\n```php\nRoute::post('/api/payments', PaymentStoreController::class)\n    -\u003emiddleware(['dpop', 'dpop.idempotency']);\n```\n\nClients must send an `Idempotency-Key` header (UUID format):\n\n```http\nPOST /api/payments\nIdempotency-Key: 550e8400-e29b-41d4-a716-446655440000\n```\n\n| Scenario | Response |\n|---|---|\n| First request | Normal response |\n| Replay with same body | Cached response + `Idempotency-Replayed: true` header |\n| Replay with different body | `409 Conflict` + `{\"error\": \"E.I.2\"}` |\n| Missing / invalid key | `422 Unprocessable` + `{\"error\": \"E.I.1\"}` |\n\n---\n\n## Error Codes\n\nAll errors return JSON `{\"error\": \"\u003ccode\u003e\"}` with the appropriate HTTP status.\n\n| Code | Step | HTTP | Description |\n|---|---|---|---|\n| `D.E.1` | 1 | 401 | Missing or non-Bearer Authorization header |\n| `D.E.2` | 2 | 401 | Invalid JWT signature |\n| `D.E.3` | 3 | 401 | JWT expired or missing `exp` claim |\n| `D.E.4` | 4 | 401 | Missing `jkt` claim in JWT |\n| `D.E.5` | 5 | 401 | Missing DPoP proof header |\n| `D.E.6` | 6 | 401 | DPoP proof `typ` is not `dpop+jwt` |\n| `D.E.7` | 7 | 401 | DPoP proof `alg` is not `ES256` or key is not EC P-256 |\n| `D.E.8` | 8 | 401 | DPoP proof JWS cryptographic signature invalid |\n| `D.E.9` | 9 | 401 | `htm` does not match request method |\n| `D.E.10` | 10 | 401 | `htu` does not match request URL |\n| `D.E.11` | 11 | 401 | `iat` outside acceptable clock skew |\n| `D.E.12` | 12 | 401 | `jti` replayed (anti-replay) |\n| `D.E.13` | 13 | 401 | JWK thumbprint does not match `jkt` claim |\n| `D.E.14` | — | 422 | JWK contains private key `d` |\n| `C.O.1` | — | 401 | Origin not in allowed origins list |\n| `S.1` | — | 401 | Required scope not present in token |\n| `E.I.1` | — | 422 | Missing or invalid `Idempotency-Key` |\n| `E.I.2` | — | 409 | Idempotency key reused with different request body |\n\n---\n\n## Client Example\n\nA minimal JavaScript client using the Web Crypto API:\n\n```js\n// Generate an EC P-256 key pair\nconst keyPair = await crypto.subtle.generateKey(\n    { name: 'ECDSA', namedCurve: 'P-256' },\n    true,\n    ['sign', 'verify'],\n);\n\nconst publicJwk = await crypto.subtle.exportKey('jwk', keyPair.publicKey);\n\n// 1. Obtain a DPoP-bound token\nconst tokenRes = await fetch('/api/dpop/token', {\n    method: 'POST',\n    headers: { 'Content-Type': 'application/json' },\n    body: JSON.stringify({ jwk: publicJwk, scope: 'read' }),\n});\nconst { data: { attributes: { token } } } = await tokenRes.json();\n\n// 2. Build a DPoP proof for each request\nasync function buildProof(method, url) {\n    const header = { alg: 'ES256', typ: 'dpop+jwt', jwk: publicJwk };\n    const payload = { htm: method, htu: url, iat: Math.floor(Date.now() / 1000), jti: crypto.randomUUID() };\n    const enc = (obj) =\u003e btoa(JSON.stringify(obj)).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n    const data = enc(header) + '.' + enc(payload);\n    const sig = await crypto.subtle.sign({ name: 'ECDSA', hash: 'SHA-256' }, keyPair.privateKey, new TextEncoder().encode(data));\n    const sigB64 = btoa(String.fromCharCode(...new Uint8Array(sig))).replace(/\\+/g, '-').replace(/\\//g, '_').replace(/=/g, '');\n    return data + '.' + sigB64;\n}\n\n// 3. Make a protected request\nconst proof = await buildProof('GET', 'https://your-app.com/api/resource');\nconst res = await fetch('/api/resource', {\n    headers: { 'Authorization': `Bearer ${token}`, 'DPoP': proof },\n});\n```\n\n---\n\n## Development\n\n```bash\ncomposer install\ncomposer test          # run PHPUnit\ncomposer pint          # fix code style\ncomposer pint:test     # check code style without fixing\ncomposer phpstan       # static analysis (level 8)\ncomposer ci            # test + pint:test + phpstan\n```\n\n---\n\n## License\n\nMIT — see [LICENSE](LICENSE).\n","project_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flabrodev%2Flaravel-dpop","html_url":"https://awesome.ecosyste.ms/projects/github.com%2Flabrodev%2Flaravel-dpop","lists_url":"https://awesome.ecosyste.ms/api/v1/projects/github.com%2Flabrodev%2Flaravel-dpop/lists"}