neocode / laravel-app-pairing
A generic, secure, reusable pairing and inter-application authentication layer for Laravel applications, using Ed25519-signed requests.
Requires
- php: ^8.3
- ext-sodium: *
- illuminate/support: ^12.0||^13.0
- laravel/prompts: ^0.3
- spatie/laravel-activitylog: ^4.10
Requires (Dev)
- larastan/larastan: ^3.9
- laravel/agent-detector: ^2.0
- laravel/chisel: ^0.1
- laravel/pao: ^1.0
- laravel/pint: ^1.29
- orchestra/testbench: ^10.0||^11.0
- pestphp/pest: ^4.6||^5.0
- pestphp/pest-plugin-laravel: ^4.1||^5.0
- pestphp/pest-plugin-type-coverage: ^4.0||^5.0
- phpstan/extension-installer: ^1.4
README
Laravel App Pairing
English | Français
A generic, secure, reusable pairing and inter-application authentication layer for Laravel applications. It lets one Laravel installation discover, pair with, and cryptographically authenticate communication against any number of partner Laravel applications — without knowing anything about what those applications are, or what data they exchange.
The package owns exactly five concerns: identity, pairing, authentication, signing, and transport. It never touches your business data or business endpoints — you keep writing your own controllers and models, and simply protect them with the middleware and signed HTTP client this package provides.
- Concepts
- Installation
- Application identity
- The bootstrap token
- The
pairing:keycommand - Pairing lifecycle
- Sending signed requests
- Protecting your own endpoints
- Testing communication
- Events
- Activity log
- Security model
- Configuration reference
- Data model
- Design decisions that differ from a literal reading of the spec
- Troubleshooting
Concepts
| Concept | What it is |
|---|---|
| Application identity | A stable app_id + descriptive metadata (name, base_url, environment, api_version) identifying this installation. Two environments of the same codebase (staging vs. production) have different app_ids. |
Bootstrap token (APP_PAIRING_TOKEN) |
A shared secret used only to authorize the initial pairing handshake. It never authenticates normal traffic and never becomes a permanent API key. |
| Pairing request | A proposal from one application to pair with another. Requests are pending, accepted, rejected, or expired. |
| Pairing | An active, keyed relationship between two applications, identified by a pairing_uuid shared by both sides. States: pending (reserved for future use — see design decisions), active, revoked. |
| Pairing key | An Ed25519 key pair. Each pairing tracks both applications' keys, with full rotation history. Private keys are encrypted at rest and never leave the application that generated them. |
| Signed request | Every normal (post-pairing) HTTP call is authenticated with app_id + pairing_id + timestamp + nonce + a body hash, all covered by an Ed25519 signature — never a bearer token. |
Installation
composer require neocode/laravel-app-pairing
Publish the configuration file:
php artisan vendor:publish --tag="pairing-config"
The package's migrations are loaded automatically — no publishing required. Run them with:
php artisan migrate
If you'd rather have the migration files in your own database/migrations directory (e.g. to customize them), publish them explicitly instead:
php artisan vendor:publish --tag="pairing-migrations"
Application identity
Add these variables to .env (see config/pairing.php for every option):
PAIRING_APP_ID= # generated once, see below — not a secret PAIRING_APP_NAME="My Application" PAIRING_BASE_URL="${APP_URL}" PAIRING_ENVIRONMENT="${APP_ENV}" PAIRING_API_VERSION=v1
PAIRING_APP_ID must be unique per installation — production and staging deployments of the same codebase must use different values. You don't need to generate this by hand: running php artisan pairing:key (see below) provisions it automatically the first time it runs, and never touches it again afterward.
If your application needs to source its identity from somewhere other than config/env (a database row, a tenant context, ...), bind your own implementation of the contract instead:
use Neocode\LaravelAppPairing\Contracts\ApplicationIdentity; $this->app->bind(ApplicationIdentity::class, MyApplicationIdentity::class);
The bootstrap token (APP_PAIRING_TOKEN)
APP_PAIRING_TOKEN=
This secret proves, during the pairing handshake only, that the requester is authorized to propose a pairing. The same value must be configured on both applications that are allowed to bootstrap a pairing with each other. It is sent as the X-Pairing-Bootstrap-Token header, is never logged, is never returned in a response, and is never used once a pairing is active.
The pairing:key command
php artisan pairing:key # generate a new token and save it to .env php artisan pairing:key --show # display the current token in plain text php artisan pairing:key --set # interactively set a token via masked input php artisan pairing:key --force # skip the overwrite confirmation
- Every invocation (regardless of flags) first checks whether
PAIRING_APP_IDis set in.env, and silently generates and saves a ULID there if it isn't. This only ever happens once per installation — an existingPAIRING_APP_IDis never regenerated or overwritten, by this or any other flag. There is no--show/--setpair for it because it isn't a secret; it's just printed to the console the one time it's generated. - Generation of
APP_PAIRING_TOKENusesrandom_bytes()(a CSPRNG), never a custom algorithm. - Overwriting an existing token asks for confirmation unless
--forceis passed; running non-interactively (--no-interaction) without--forcefails safely instead of hanging or silently overwriting. --setnever accepts the secret as a CLI argument (--set="..."is deliberately not supported) because argument values can leak into shell history, process lists, and CI logs. It always prompts with masked input.- Rotating
APP_PAIRING_TOKENnever revokes already-active pairings — it only affects future bootstrap handshakes.
Pairing lifecycle
All of this is driven through the LaravelAppPairing facade (or by injecting Neocode\LaravelAppPairing\LaravelAppPairing), which your application's own controllers/Livewire components/console commands call into. The package does not ship any UI.
1. Requesting a pairing
use Neocode\LaravelAppPairing\Facades\LaravelAppPairing; $request = LaravelAppPairing::requestPairing('https://partner-app.example.com');
This generates an Ed25519 key pair for this new relationship, stores the private half encrypted, and POSTs a bootstrap-token-authenticated request to the partner containing this application's identity and public key. The request starts pending.
2. Receiving a request
Incoming requests arrive automatically at POST {prefix}/pairing-requests (see configuration for the prefix), authenticated by the bootstrap token, validated, checked for idempotent replays (by request_id) and for conflicting existing pairings, and stored as a pending PairingRequest. Listen for PairingRequested to notify an admin, e.g. in your own UI's "incoming requests" list.
3. Accepting a request
use Neocode\LaravelAppPairing\Models\PairingRequest; $pairing = LaravelAppPairing::accept(PairingRequest::findOrFail($id));
This generates this application's own key pair for the relationship, activates the pairing locally, and queues delivery of the response (this application's public key + the shared pairing_id) back to the original requester. The requester activates its own side once it validates that response matches its original request.
4. Rejecting a request
LaravelAppPairing::reject(PairingRequest::findOrFail($id));
No pairing is ever created for a rejected request.
5. Revoking a pairing
use Neocode\LaravelAppPairing\Models\Pairing; LaravelAppPairing::revoke(Pairing::findOrFail($id), reason: 'no longer needed');
Revocation is immediate and local — it never waits on the partner being reachable. The remote notification is queued and retried independently. A revoked pairing never accepts signed requests again; pair again from scratch if the relationship needs to be restored.
6. Rotating keys
LaravelAppPairing::rotateKey(Pairing::findOrFail($id));
Generates a new local key pair, starts using it immediately for outgoing requests, and notifies the partner (signed with the previous key, to prove continuity). The partner keeps accepting the old key for a short grace window (pairing.key_rotation_grace_period) while the rotation propagates, so in-flight requests are not spuriously rejected. Rotation never revokes the pairing itself.
Sending signed requests
Once a pairing is active, get a ready-to-use signed HTTP client for it:
$client = LaravelAppPairing::clientFor($pairing); $response = $client->post('/api/v1/orders', $payload); $response = $client->get('/api/v1/orders/42');
The client automatically sets app_id, pairing_id, timestamp, nonce, the body hash, and the Ed25519 signature; applies your configured timeout and retry policy; and enforces HTTPS when pairing.https.required is enabled. You never construct these headers yourself, and this is the only place business endpoints and the pairing protocol touch — the package has no opinion about what $payload contains.
Protecting your own endpoints
Apply the pairing.auth middleware to any route that should only be callable by an authenticated partner:
Route::middleware('pairing.auth')->post('/api/v1/orders', OrderController::class);
The middleware verifies the pairing exists and is active, that app_id/pairing_id match, that the timestamp and nonce are fresh, and that the signature is valid — then makes the resolved Pairing available via $request->attributes->get('pairing'). Your controller never re-implements any of this.
Testing communication
$result = LaravelAppPairing::testCommunication($pairing); // [ // 'status' => 'success', // 'pairing_id' => '...', // 'remote_application' => ['app_id' => '...', 'name' => '...', 'api_version' => '...'], // 'https' => true, // 'authentication' => 'valid', // 'signature' => 'valid', // 'response_time_ms' => 42, // ]
This exercises the real signed protocol end-to-end (not just an HTTP 200) against a dedicated health/authentication endpoint, and reports a structured result you can render in your own "test connection" UI — including a graceful status: 'failed' result (with an error code) for timeouts, unreachable hosts, or authentication failures, rather than throwing.
Events
| Event | Fired when |
|---|---|
PairingRequested |
An incoming pairing request is received. |
PairingAccepted |
A pairing becomes active (on either side). |
PairingRejected |
A pairing request is rejected (on either side). |
PairingRevoked |
A pairing is revoked (locally or on notice from the partner). |
PairingKeyRotationRequested |
A key rotation is about to start. |
PairingKeyRotated |
A new key becomes active for a pairing (local rotation or an incoming rotation notice). |
CommunicationTested |
A communication test completes, successfully or not. |
PairingAuthenticationFailed |
An inbound signed request fails authentication, with the reason code. |
Activity log
When spatie/laravel-activitylog is configured (it ships as a dependency of this package), the following events are journaled under the pairing log name (configurable): pairing_requested, pairing_accepted, pairing_rejected, pairing_revoked, pairing_key_rotated, communication_tested, pairing_authentication_failed, pairing_request_expired. Logged context always includes the pairing/request id and remote app metadata — never APP_PAIRING_TOKEN, APP_KEY, private keys, full signatures, or Authorization-style headers. Per-request business traffic is intentionally not logged here; use your normal Laravel/infrastructure logs for that.
Disable it entirely with PAIRING_ACTIVITY_LOG_ENABLED=false.
MySQL/PostgreSQL note: this package's models use ULID (string) primary keys, but
spatie/laravel-activitylog's own stock migration typessubject_idas an unsigned bigint, which truncates ULIDs on strict database engines (SQLite silently tolerates it, which is why this kind of bug typically only surfaces in production-like databases). You don't need to do anything about this yourself: this package ships its ownactivity_logmigration that creates the table with a ULID-compatible subject morph if it doesn't exist yet, or safely widenssubject_idif the table already exists — including if your application already hasspatie/laravel-activityloginstalled and migrated independently of this package (for your own, unrelated activity logging). This package's migration is intentionally dated far in the future so it always runs last in any migration batch, after any migration your own application ships for the same table, so it always sees the final state rather than racing it. Thecausermorph is left untouched either way (it typically stays an auto-incrementing user id, which is fine).
Security model
- Cryptography: Ed25519 via libsodium (
sodium_crypto_sign_keypair/sodium_crypto_sign_detached/sodium_crypto_sign_verify_detached). No custom cryptographic primitive is implemented anywhere in this package. - Private keys are encrypted at rest with Laravel's own encrypter (an
encryptedEloquent cast, backed byAPP_KEY), stored in aTEXTcolumn, never logged, never serialized into a queued job payload, never returned from any endpoint, and hidden from array/JSON model serialization. ProtectingAPP_KEYis therefore critical — anyone who obtains it and the database can decrypt every stored private key. - The canonical signed representation covers
METHOD,PATH,TIMESTAMP,NONCE,SHA-256(BODY),APP_ID, andPAIRING_ID— never the body alone, and never headers that aren't explicitly part of this representation. - Anti-replay: every signed request carries a timestamp (checked against
pairing.signature_ttl) and a nonce (remembered for the same TTL via the cache-backedNonceStore, swappable via theNonceStorecontract). A reused nonce is rejected. - Bootstrap vs. normal auth are fully separate mechanisms —
APP_PAIRING_TOKENonly ever appears on thepairing.bootstrap-protected handshake endpoints; every other endpoint usespairing.auth(Ed25519 signatures). - HTTPS is enforced by both middleware whenever
pairing.https.requiredis true (the default). - Idempotency: pairing requests, acceptance, rejection, revocation, and rotation are all safe to retry — matched by
request_id(requests) or by current state (revocation/rotation are no-ops once already applied).
Configuration reference
See the fully-commented config/pairing.php for every key. The highlights:
| Key | Purpose |
|---|---|
identity.* |
This installation's app_id, name, base_url, environment, api_version. |
bootstrap_token |
APP_PAIRING_TOKEN. |
https.required |
Reject non-HTTPS pairing traffic (default true). |
routes.prefix / routes.version / routes.middleware |
Where the package's own routes are mounted. |
signature_ttl |
Timestamp/nonce validity window, in seconds (default 300). |
pairing_request_ttl |
How long an unanswered pairing request stays pending before pairing:prune-requests expires it. |
key_rotation_grace_period |
How long a rotated-out key is still accepted, in seconds. |
http.* |
Timeout, connect timeout, retry count/delay for the signed client and bootstrap client. |
queue.* |
Connection/queue/retry policy for revocation and rotation notifications. |
activity_log.* |
Enable/disable and name the activity log channel. |
Run php artisan pairing:prune-requests on a schedule (or let the package's own daily schedule entry do it) to expire stale pending requests.
Data model
| Table | Purpose |
|---|---|
pairing_applications |
Known applications — exactly one row per installation is flagged is_local; every partner gets its own row. |
pairing_requests |
The handshake record: request_id, direction, remote metadata, status (pending/accepted/rejected/expired). |
pairing_pairings |
The relationship itself: shared pairing_uuid, both applications, status (pending/active/revoked), timestamps. |
pairing_keys |
Full key history per pairing, tagged owner (local/remote) and status (active/revoked), so rotations are auditable. |
Design decisions that differ from a literal reading of the spec
- The
Pairingmodel'spendingstatus is not currently reachable in the shipped flow. APairingrow is created inpendingthe moment an outgoing request is sent (so its generated local key has somewhere to live before the relationship is confirmed), and is deleted if that request is rejected or expires. It only ever becomes visible asactive. This keeps pairing requests and active pairings cleanly separated, as required, without a dangling intermediate state to reason about. - A pairing's own private key material is never deleted on revocation, only marked
revoked— this lets the queued revocation notification still be signed correctly even if it executes after the local state has already flipped, without weakening any authentication guarantee (a revoked pairing's incoming requests are always rejected bypairing.authregardless of key status). - An incoming revocation notice does not itself queue a new outbound revocation notice. The side that revoked first already knows; echoing it back would just bounce indefinitely.
Troubleshooting
- "No PAIRING_APP_ID is configured" — run
php artisan pairing:key, which provisions it automatically (see Application identity), or bind a customApplicationIdentity. - "No APP_PAIRING_TOKEN is configured" — run
php artisan pairing:keyon both applications and make sure they match. pairing_not_activeon every request — the pairing was revoked, or you're pointing at the wrongpairing_id/app_id.nonce_reused— your client retried a request whose nonce had already been consumed; the signed client generates a fresh nonce per attempt automatically, so this usually indicates a client outside this package reusing a signed payload.timestamp_out_of_window— check clock skew between the two applications; the acceptable window ispairing.signature_ttlseconds.
Changelog
Please see CHANGELOG for more information on what has changed recently.
Contributing
Thank you for considering contributing to Laravel App Pairing! Please review our contributing guide to get started.
Security Vulnerabilities
Please review our security policy on how to report security vulnerabilities.
Credits
License
Laravel App Pairing is open-sourced software licensed under the MIT license.