Weave Code
Weaver
Helps Laravel developers discover, compare, and choose open-source packages. See popularity, security, maintainers, and scores at a glance to make better decisions.
Feedback
Share your thoughts, report bugs, or suggest improvements.
Subject
Message

Expiring Hash Laravel Package

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.

View on GitHub
Deep Wiki
Context7

Getting Started

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.

Implementation Patterns

Wrap it, don't sprinkle 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.

Minting links in a controller or artisan command

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.

Validating in middleware

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.

Blade and Notifications

<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.');
}

Integration tips

  • Queue-safe: TTLs should be long enough to survive queue latency plus the user's inbox lag. A 5-minute TTL on a queued email is a support ticket waiting to happen — use hours for email, seconds for API handoffs.
  • Normalize the payload once. Whatever you hash must be byte-identical to what you validate. Paths with trailing slashes, mixed case, or query strings will silently produce false negatives. The strtolower(trim(...)) in the adapter is doing real work.
  • Include a resource identifier in the payload, not just the route path, so a hash for reports/1 cannot open reports/2.

Gotchas and Tips

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.

Weaver

How can I help you explore Laravel packages today?

Conversation history is not saved when not logged in.
Prompt
Add packages to context
No packages found.
coleus/frame
thtg88/journalism
filagora/filagora-ai-forums
rlacerda83/query-parser
socialdept/atp-orm
cboxdk/laravel-assistant-client
socialdept/atp-resolver
socialdept/tenant-domains
tautvydasr/blade-class-names
socialdept/color-scales
maherelgamil/controller
coleus/widgets
coleus/table
coleus/users
coleus/music
coleus/health
coleus/calendar
coleus/notes
coleus/support
coleus/settings