NewWe open-sourced 50+ Laravel packages
Custom AI apps, agents and automation — Roundly ConsultingRoundly
All packages
Sentinel for Laravel

Nonces and single-use URLs

A nonce is a single-use, purpose-bound random token: a password-reset or email-verification code, a one-time download, a confirmation step.

$nonce = Sentinel::nonces()->issue('password-reset', ttl: 900, subject: $user);

$nonce->value;        // 43 base64url characters (≈ 256 bits, nonces.length); only its SHA-256 is stored
$nonce->purpose;
$nonce->expiresAt;

Sentinel::nonces()->consume('password-reset', $token, $user);        // true exactly once
Sentinel::nonces()->consumeOrFail('password-reset', $token, $user);  // NonceRejectedException (403) otherwise
  • Only SHA-256(value) is stored — a database dump cannot replay a nonce.
  • Purpose: ^[a-z0-9][a-z0-9:._-]{0,127}$ (InvalidPurposeException otherwise — on issue and on consume).
  • TTL: nonces.ttl (900 s) unless given; a given TTL must be 1–2 592 000 seconds.
  • Consuming is one UPDATE statement; success means exactly one row changed. Of eight concurrent consumers on PostgreSQL and MySQL, exactly one wins.
  • A wrong purpose, another subject, an expired, consumed or unknown nonce are indistinguishable to the caller (false). A nonce issued without a subject is consumed without one.

Single-use signed URLs

$url = Sentinel::nonces()->signedRoute('exports.download', ['export' => $export->id], ttl: 600);

Route::get('/exports/{export}', DownloadExport::class)
    ->name('exports.download')
    ->middleware('sentinel.single-use');

signedRoute() issues a nonce for the purpose url:<route name>, adds it as _nonce and returns Laravel’s temporary signed URL. sentinel.single-use first requires a valid Laravel signature — a tampered or expired link is refused before the nonce is touched — then consumes the nonce for that route. The second visit, or a nonce moved to another route, answers 403 (application/problem+json, nonce_rejected).

Flat forms

use RoundlyConsulting\Sentinel\DataTransferObjects\ConsumeNonceRequest;
use RoundlyConsulting\Sentinel\DataTransferObjects\IssueNonceRequest;
use RoundlyConsulting\Sentinel\DataTransferObjects\PruneOptions;
use RoundlyConsulting\Sentinel\DataTransferObjects\SignedRouteRequest;

$nonce = Sentinel::issueNonce(new IssueNonceRequest('password-reset', ttl: 900, subject: $user));
Sentinel::consumeNonce(new ConsumeNonceRequest('password-reset', $nonce->value, $user));
$url = Sentinel::signedRoute(new SignedRouteRequest('exports.download', ['export' => $export->id], ttl: 600));
$pruned = Sentinel::prune(new PruneOptions(idempotency: true, nonces: true, dryRun: true));   // counts only

Stores and pruning

database (the default) uses sentinel_nonces on sentinel.database.connection; cache needs a lock-capable store; bind Contracts\NonceStore for your own (implement Contracts\InspectsNonces too, or deferred signature verification refuses it — see Extending). The RFC 9421 verifier remembers each signature’s nonce in the same store — only after the signature verified, so unauthenticated traffic cannot flood it. sentinel:prune (scheduled daily) deletes expired nonces and idempotency keys:

use RoundlyConsulting\Sentinel\DataTransferObjects\PruneOptions;

$result = Sentinel::prune();                                                   // both, PruneResult
$result = Sentinel::prune(new PruneOptions(idempotency: false, nonces: true, dryRun: true));
$result->idempotencyKeys;    // deleted (or, on a dry run, would be deleted)
$result->nonces;

Cache stores expire entries on their own and report 0. An idempotency key still held by a live lease is never pruned.

Signature nonces: verify now, commit later

A webhook receiver that may still refuse a signed request — a 400 for a malformed payload, a 500 — should not burn its nonce, or the sender’s identical retry is replayed for the rest of the window. Sentinel::signatures()->verifyDeferred() checks that the nonce is unused without recording it, and commitNonce() records it once you accept the request — atomically, under the same scope and lifetime as the eager path, so of concurrent copies exactly one commits. It needs a nonce store that implements Contracts\InspectsNonces; the built-in database and cache stores do. The full flow is under HTTP message signatures:

use RoundlyConsulting\Sentinel\Exceptions\HttpSignatureException;

$deferred = Sentinel::signatures()->verifyDeferred($request, 'partners');   // DeferredSignature, nonce NOT recorded

$payload = $this->validatePayload($request);        // throw a 400 here: the nonce stays free for the retry

$signature = Sentinel::signatures()->commitNonce($deferred);   // VerifiedSignature — now it is used
// … act on the request. A copy that lost the race threw HttpSignatureException (replayed).

Show your open-source love

This package is free and MIT-licensed. If it saves you time, a one-off donation or a Patreon membership keeps it maintained, tested and documented.

More ways to support, including crypto

By donating, you agree to our donation terms.

Want this built into your product?

We integrate our packages into custom Laravel and AI builds. Tell us what you're working on and we'll reply within 48 hours.