apinstein/expiring-hash
Expiring Hash provides a simple PHP API for generating time-limited hashes and tokens, ideal for signed URLs, passwordless logins, and temporary cache keys. Values expire automatically, keeping your app secure without extra cleanup.
1. Install and pin the package.
composer require apinstein/expiring-hash
The package is a single-purpose, framework-agnostic utility (no service provider, no facade, no auto-discovery, ~13 stars, 0 dependents). Treat it as vendored code: pin an exact version in composer.json and be prepared to fork it if PHP compatibility breaks on your next upgrade.
2. Add a secret to your environment.
EXPIRING_HASH_SECRET=base64:... # generate with: php artisan key:generate --show
Never reuse APP_KEY for this — you want to be able to rotate signing secrets without invalidating encrypted sessions/cookies.
3. Look at the package's public surface first. It's tiny: construct with a secret, produce a hash for a payload plus a TTL, and validate a hash against a payload. Confirm the exact method names in the README before wiring anything up — the whole library is roughly one class.
4. Bind it in the container so you're not new-ing it in controllers:
// config/expiring-hash.php
return [
'secret' => env('EXPIRING_HASH_SECRET'),
'download_ttl' => env('EXPIRING_HASH_DOWNLOAD_TTL', 900), // seconds
];
// app/Providers/ExpiringHashServiceProvider.php
namespace App\Providers;
use Illuminate\Support\ServiceProvider;
class ExpiringHashServiceProvider extends ServiceProvider
{
public function register(): void
{
$this->app->singleton(\Apinstein\ExpiringHash\ExpiringHash::class, function ($app) {
return new \Apinstein\ExpiringHash\ExpiringHash(
config('expiring-hash.secret')
);
});
}
}
Register it in bootstrap/providers.php (Laravel 11+) or config/app.php.
First use case: temporary access to a file or generated report. A controller mints a short-lived URL, sends it by mail/Slack, and a middleware validates the hash before streaming the file. That's the 90% case — everything below is variations on it.
The package's API is low-level and its error handling is minimal. Put a thin adapter in front so you own the payload format, the TTL policy, and the failure semantics:
namespace App\Support;
use Apinstein\ExpiringHash\ExpiringHash;
class SignedLink
{
public function __construct(private ExpiringHash $hasher) {}
/** Scope the payload so a "download" hash can't be replayed on a "preview" route. */
public function payload(string $scope, string $value): string
{
return $scope . '|' . strtolower(trim($value));
}
public function make(string $scope, string $value, ?int $ttl = null): array
{
$ttl = $ttl ?? (int) config('expiring-hash.download_ttl');
$payload = $this->payload($scope, $value);
$hash = $this->hasher->hash($payload, $ttl);
return [
'value' => $value,
'hash' => $hash,
'expires_at' => now()->addSeconds($ttl),
];
}
public function check(string $scope, string $value, ?string $hash): bool
{
if (blank($hash)) {
return false;
}
return (bool) $this->hasher->validate($hash, $this->payload($scope, $value));
}
}
Bind SignedLink as a singleton alongside the hasher. Every call site now goes through make()/check(), and swapping the implementation later (see "Gotchas") is a one-file change.
public function link(Request $request, SignedLink $links)
{
$data = $request->validate(['path' => ['required', 'string']]);
$signed = $links->make('download', $data['path']);
return response()->json([
'url' => url($data['path']) . '?' . http_build_query(['hash' => $signed['hash']]),
'expires_at' => $signed['expires_at']->toIso8601String(),
]);
}
For emails, generate the URL inside the Mailable or in the queued job that dispatches it — never store a pre-signed URL in the database, because the clock keeps running and the TTL is baked into the hash.
public function handle(Request $request, Closure $next)
{
$links = app(SignedLink::class);
if (! $links->check('download', $request->path(), $request->query('hash'))) {
abort(403, 'This link has expired or is invalid.');
}
return $next($request);
}
Register it as an alias ('signed.link') in bootstrap/app.php and apply per-route:
Route::get('/reports/{report}', ReportDownloadController::class)
->middleware('signed.link');
Keep the validation in middleware rather than the controller so the protected route can't accidentally be reached through another action.
<a href="{{ $downloadUrl }}">Your report (expires {{ $expiresAt->diffForHumans() }})</a>
In a notification:
public function toMail($notifiable): MailMessage
{
$signed = app(SignedLink::class)->make('download', $this->report->path, 86400);
return (new MailMessage)
->line('Your report is ready.')
->action('Download', url($this->report->path).'?hash='.$signed['hash'])
->line('This link expires in 24 hours.');
}
strtolower(trim(...)) in the adapter is doing real work.reports/1 cannot open reports/2.Laravel already does this — check you actually need the package. URL::temporarySignedRoute() plus the built-in signed middleware covers the 99% case of "expiring URL to a named route," with the expiry in the query string and HMAC validation handled for you. Reach for expiring-hash only when you need to sign something that isn't a route (an S3 key, an arbitrary string, a payload for a non-Laravel service), or when you're integrating with a system that must validate the same hash outside Laravel. If a reviewer asks "why not signed routes?", have an answer.
The hash embeds an absolute expiry. That means the signed value is time-dependent — don't cache it, don't dedupe on it, and don't compare two hashes for equality to determine "same link," because two calls a second apart produce different strings.
Clock skew will bite you in multi-server setups. If your web and worker nodes disagree on time() by more than a few seconds, links minted on one node will fail validation on another near the boundary. Ensure NTP is healthy, and consider adding a small grace period by minting with ttl + 60 and enforcing the real expiry in your own middleware if precision matters.
Testing time-based expiry is awkward. If the package calls time() directly (likely, given its age), Laravel's Carbon::setTestNow() and $this->travel(10)->minutes() won't affect it. Options: inject a clock via a forked copy, use sleep() in a slow-marked test, or assert on your adapter (make() returns expires_at) rather than on the package's internals.
Timing attacks. Old signature-verification code often uses == instead of hash_equals(). If the package does, wrap the comparison — or verify in your adapter and treat the package purely as a hash generator. This matters most if hashes are exposed to unauthenticated users, which is the entire point of the feature.
There's no replay protection. A valid hash works until it expires, as many times as it's used. If you need single-use links (password resets, one-time downloads), add a nonce to the payload and record consumption in the cache: Cache::add("signed:{$nonce}", true, $ttl) at validation time.
Nothing is encrypted — the payload is not in the hash. In most implementations the hash is a MAC; the value it signs travels in the clear in the URL. Don't sign a payload containing secrets or PII, and remember query strings land in access logs, Referer headers, and browser history.
Rotate secrets carefully. Changing EXPIRING_HASH_SECRET instantly invalidates every outstanding link. If that's unacceptable, support two keys during rotation: mint with the new one, validate against new-then-old, and drop the old key after the longest TTL has elapsed.
Beware the empty secret. If EXPIRING_HASH_SECRET is missing, config() returns null and you may end up signing with an empty key — which is equivalent to no protection. Add a guard in the service provider: throw_if(blank(config('expiring-hash.secret')), 'EXPIRING_HASH_SECRET is not set.').
URL encoding of the hash. Base64 output contains +, /, and =, which get mangled if the hash isn't URL-encoded. Always build links with http_build_query() or urlencode(), and read it back with $request->query('hash') (which decodes for you) — never $_GET directly.
Fallback plan: it's ~20 lines of hash_hmac. Given the package's age and zero dependents, keep in mind that the whole functionality is hash_hmac('sha256', $payload.'|'.$expiresAt, $secret) plus an expiry comparison. If you hit a PHP 8 incompatibility or an abandoned dependency, replacing it with a small local class is a low-risk afternoon — which is another reason to route all calls through the adapter from day one.
How can I help you explore Laravel packages today?