PHPackages                             zenstruck/signed-url-bundle - PHPackages - PHPackages  [Skip to content](#main-content)[PHPackages](/)[Directory](/)[Categories](/categories)[Trending](/trending)[Leaderboard](/leaderboard)[Changelog](/changelog)[Analyze](/analyze)[Collections](/collections)[Log in](/login)[Sign up](/register)

1. [Directory](/)
2. /
3. [Utility &amp; Helpers](/categories/utility)
4. /
5. zenstruck/signed-url-bundle

Abandoned → [zenstruck/uri](/?search=zenstruck%2Furi)ArchivedSymfony-bundle[Utility &amp; Helpers](/categories/utility)

zenstruck/signed-url-bundle
===========================

Helpers for signing and verifying urls with support for temporary and single-use urls.

v0.1.0(4y ago)66011[3 issues](https://github.com/zenstruck/signed-url-bundle/issues)MITPHPPHP &gt;=7.4

Since Dec 22Pushed 4y ago1 watchersCompare

[ Source](https://github.com/zenstruck/signed-url-bundle)[ Packagist](https://packagist.org/packages/zenstruck/signed-url-bundle)[ Docs](https://github.com/zenstruck/signed-url-bundle)[ GitHub Sponsors](https://github.com/kbond)[ RSS](/packages/zenstruck-signed-url-bundle/feed)WikiDiscussions 1.x Synced 1mo ago

READMEChangelog (1)Dependencies (5)Versions (2)Used By (0)

zenstruck/signed-url-bundle
===========================

[](#zenstrucksigned-url-bundle)

[![CI Status](https://github.com/zenstruck/signed-url-bundle/workflows/CI/badge.svg)](https://github.com/zenstruck/signed-url-bundle/actions?query=workflow%3ACI)[![codecov](https://camo.githubusercontent.com/9413e4f9fb934ab0d81b6b74a2e960386de86b5a7298a57b21b5a0d2634306eb/68747470733a2f2f636f6465636f762e696f2f67682f7a656e73747275636b2f7369676e65642d75726c2d62756e646c652f6272616e63682f312e782f67726170682f62616467652e7376673f746f6b656e3d384d49595a53514e365a)](https://codecov.io/gh/zenstruck/signed-url-bundle)

Helpers for signing and verifying urls with support for temporary and single-use urls. Some common use cases include:

- [Stateless Password Resets](#stateless-password-resets)
- [Stateless Email Verification](#stateless-email-verification)
- [Stateless Verified Change Email](#stateless-verified-change-email)

```
use Zenstruck\SignedUrl\Generator;

public function sendPasswordResetEmail(User $user, Generator $generator)
{
    $resetUrl = $generator->build('password_reset_route', ['id' => $user->getId()])
        ->expires('+1 day')
        ->singleUse($user->getPassword())
    ;

    // create email with $resetUrl and send
}
```

```
use Zenstruck\SignedUrl\Verifier;
use Zenstruck\SignedUrl\Exception\UrlVerificationFailed;

public function resetPasswordAction(User $user, Verifier $urlVerifier)
{
    try {
        $urlVerifier->verifyCurrentRequest(singleUseToken: $user->getPassword());
    } catch (UrlVerificationFailed $e) {
        $this->flashError($e->messageKey()); // safe reason to show user

        return $this->redirect(...);
    }

    // continue
}
```

Why This Bundle?
----------------

[](#why-this-bundle)

Symfony includes a `UriSigner` (in fact, this bundle uses this) but it doesn't have out of the box support for temporary/single-use urls. Symfony 5.2 introduced [login links](https://symfony.com/blog/new-in-symfony-5-2-login-links) that has these features but is restricted to these type of links only.

[`tilleuls/url-signer-bundle`](https://packagist.org/packages/tilleuls/url-signer-bundle) is another bundle that provides expiring signed urls but not single-use (out of the box).

Additionally, this bundle provides the following features:

1. [`SignedUrl` Object](#signedurl-object) that contains metadata about the created signed url.
2. Explicit exceptions so you can know exactly why verification failed and optionally relay this to the user (ie *the url has already been used* or *the url has expired*)

Installation
------------

[](#installation)

```
composer require zenstruck/signed-url-bundle
```

**NOTE**: If not added automatically by `symfony/flex`, enable `ZenstruckSignedUrlBundle`.

Generate
--------

[](#generate)

The `Zenstruck\SignedUrl\Generator` is an auto-wireable service that is used to generate signed urls for your Symfony routes. By default, all generated urls are absolute.

### Standard Signed Urls

[](#standard-signed-urls)

`Generator` service is an instance of `Symfony\Component\Routing\Generator\UrlGeneratorInterface`. Calling `Generator::generate()` creates a *standard* signed url (no expiration). These are absolute by default.

```
use Zenstruck\SignedUrl\Generator;

/** @var Generator $generator */

$generator->generate('route1'); // http://example.com/route1?_hash=...
$generator->generate('route2', ['parameter1' => 'value']); // http://example.com/route2/value?_hash=...
$generator->generate('route3', [], Generator::ABSOLUTE_PATH); // /route2/value?_hash=...
```

### Signed URL Builder

[](#signed-url-builder)

You can create a signed, [temporary](#temporary-urls) and/or [single-use](#single-use-urls)URL using `Generator::build()`.

```
/** @var Zenstruck\SignedUrl\Generator $generator */

(string) $generator->build('reset_password', ['id' => $user->getId()])
    ->expires('+1 hour')
    ->singleUse($user->getPassword())
;
```

### `SignedUrl` Object

[](#signedurl-object)

`Generator::build()` creates a signed URL builder, calling `create()` on this returns a `SignedUrl` object with context for the url:

```
/** @var Zenstruck\SignedUrl\Generator $generator */

$signedUrl = $generator->build('reset_password', ['id' => $user->getId()])
    ->expires('+1 hour')
    ->singleUse($user->getPassword())
    ->create()
;

/** @var Zenstruck\SignedUrl $signedUrl */
(string) $signedUrl; // the actual URL
$signedUrl->expiresAt(); // \DateTimeImmutable
$signedUrl->isTemporary(); // true
$signedUrl->isSingleUse(); // true
```

### Temporary Urls

[](#temporary-urls)

These urls expire (cannot be verified) after a certain time. They are also signed so cannot be tampered with.

```
/** @var Zenstruck\SignedUrl\Generator $generator */

(string) $generator->build('route1')->expires('+1 hour'); // http://example.com/route1?__expires=...&_hash=...
(string) $generator->build('route2', ['parameter1' => 'value'])->expires('+1 hour'); // http://example.com/route2/value?__expires=...&_hash=...

// use # of seconds
(string) $generator->build('route1')->expires(3600); // http://example.com/route2/value?__expires=...&_hash=...

// use an explicit \DateTime
(string) $generator->build('route1')->expires(new \DateTime('+1 hour')); // http://example.com/route2/value?__expires=...&_hash=...
```

Verification
------------

[](#verification)

The `Zenstruck\SignedUrl\Verifier` is an auto-wireable service that is used to verify signed urls.

```
use Zenstruck\SignedUrl\Exception\UrlVerificationFailed;

/** @var Zenstruck\SignedUrl\Verifier $verifier */
/** @var string $url */
/** @var Symfony\Component\HttpFoundation\Request $request */

// simple usage: return true if valid and non-expired (if applicable), false otherwise
$verifier->isVerified($url);
$verifier->isVerified($request); // can pass Symfony request object
$verifier->isCurrentRequestVerified(); // verifies the current request (fetched from RequestStack)

// try/catch usage: catch exceptions to provide better feedback to users
try {
    $verifier->verify($url);
    $verifier->verify($request); // alternative
    $verifier->verifyCurrentRequest(); // alternative
} catch (UrlVerificationFailed $e) {
    $e->url(); // the url used
    $e->getMessage(); // Internal message (ie for logging)
    $e->messageKey(); // Safe message with reason to show the user (or use with translator)
}
```

**NOTE:** See [Verification Exceptions](#verification-exceptions) for more information on the thrown exception.

Single-Use Urls
---------------

[](#single-use-urls)

These urls are generated with a token that should change once the url has been used.

**CAUTION:** It is up to you to determine this token and depends on the context. This value **MUST** change after the token is successfully *used*, else it will still be valid.

A good example is a password reset. For these urls, the token would be the current user's password. Once they successfully change their password the token wouldn't match so the url would become invalid.

**NOTE**: The URL is first hashed with this token, then hashed again with the app-level secret to ensure it hasn't been tampered with.

```
/** @var Zenstruck\SignedUrl\Generator $generator */

// !! This will be the single-use token that changes once "used" !!
$password = $user->getPassword();

$url = $generator->build('reset_password', ['id' => $user->getId()])
    ->singleUse($password)
    ->create()
;
```

### Single-Use Verification

[](#single-use-verification)

For validating [single-use urls](#single-use-urls), you need to pass a token to the Verifier's verify methods:

```
use Zenstruck\SignedUrl\Exception\UrlVerificationFailed;

/** @var Zenstruck\SignedUrl\Verifier $verifier */
/** @var string $url */
/** @var Symfony\Component\HttpFoundation\Request $request */

// !! This is the single-use token. If the url was generated with a different password verification will fail !!
$password = $user->getPassword();

$verifier->isVerified($url, $password);
$verifier->isVerified($request, $password);
$verifier->isCurrentRequestVerified($password);

// try/catch usage: catch exceptions to provide better feedback to users
try {
    $verifier->verify($url, $password);
    $verifier->verify($request, $password); // alternative
    $verifier->verifyCurrentRequest($password); // alternative
} catch (UrlVerificationFailed $e) {
    $e->messageKey(); // "URL has already been used." (if failed for this reason)
}
```

### Token Objects

[](#token-objects)

The single-use token is required for both generating and verifying the url. These are likely done in different parts of your application. To avoid duplicating the generation of your token, it is recommended to wrap the logic into simple *token objects* that are `\Stringable`:

```
final class ResetPasswordToken
{
    public function __construct(private User $user) {}

    public function __toString(): string
    {
        return $this->user->getPassword();
    }
}
```

Generate the url using this token object:

```
/** @var Zenstruck\SignedUrl\Generator $generator */

$generator->build('reset_password', ['id' => $user->getId()])->singleUse(new ResetPasswordToken($user));
```

When verifying, use the token object here as well:

```
/** @var Zenstruck\SignedUrl\Verifier $verifier */

$verifier->isVerified($url, new ResetPasswordToken($user));
$verifier->verify($url, new ResetPasswordToken($user));
$verifier->isCurrentRequestVerified(new ResetPasswordToken($user));
$verifier->verifyCurrentRequest(new ResetPasswordToken($user));
```

Auto-Verify Routes
------------------

[](#auto-verify-routes)

You can auto-verify specific routes using a routing option or attribute. Before these controllers are called, an event listener verifies the route and throws an `HttpException` (`403` by default) on failure. You do not have the option to intercept and provide a friendly message to the user. Additionally, single-use URL verification is not possible.

This feature needs to be enabled:

```
# config/packages/zenstruck_signed_url.yaml

zenstruck_signed_url:
    route_verification: true
```

Add the `Zenstruck\SignedUrl\Attribute\Signed` attribute to the controller you want auto-verified (can be added to the class to mark all methods as signed):

```
use Zenstruck\SignedUrl\Attribute\Signed;

#[Signed]
#[Route(...)]
public function action1() {} // throws a 403 HttpException if verification fails

#[Signed(status: 404)]
#[Route(...)]
public function action1() {} // throw a 404 exception instead
```

Alternatively, a `signed` route option can be added to your route definition:

```
# config/routes.yaml

action1:
    path: /action1
    options: { signed: true } # throws a 403 HttpException if verification fails

action2:
    path: /action2
    options: { signed: 404 } # throw a 404 exception instead
```

Verification Exceptions
-----------------------

[](#verification-exceptions)

Verification can fail for the following reasons (in this order):

1. Signature missing or invalid (URL has been tampered with).
2. If the URL has an expiration and has expired.
3. Single-use URL has been *used*.

Each of the above reasons has a corresponding exception that can be caught separately (all exceptions are instances of `Zenstruck\SignedUrl\Exception\UrlVerificationFailed`):

```
use Zenstruck\SignedUrl\Exception\UrlVerificationFailed;
use Zenstruck\SignedUrl\Exception\UrlHasExpired;
use Zenstruck\SignedUrl\Exception\UrlAlreadyUsed;

/** @var Zenstruck\SignedUrl\Verifier $verifier */

try {
    $verifier->verifyCurrentRequest($user->getPassword());
} catch (UrlHasExpired $e) {
    // this exception makes the expiration available
    $e->expiredAt(); // \DateTimeImmutable
    $e->messageKey(); // "URL has expired."
    $e->url(); // the URL that failed verification
} catch (UrlAlreadyUsed $e) {
    $e->messageKey(); // "URL has already been used."
    $e->url(); // the URL that failed verification
} catch (UrlVerificationFailed $e) {
    // must be last as a "catch all"
    $e->messageKey(); // "URL Verification failed."
    $e->url(); // the URL that failed verification
}
```

Full Default Configuration
--------------------------

[](#full-default-configuration)

```
zenstruck_signed_url:

    # The secret key to sign urls with
    secret:               '%kernel.secret%'

    # Enable auto route verification (trigger with "signed" route option or "Zenstruck\SignedUrl\Attribute\Signed" attribute)
    route_verification:   false
```

Cookbook
--------

[](#cookbook)

The following are pseudo-code recipes for possible use-cases for this bundle:

### Stateless Password Resets

[](#stateless-password-resets)

Generate a password-reset link that has a 1 day expiration and is considered *used* when the password changes:

```
/** @var \Zenstruck\SignedUrl\Generator $generator */
/** @var \Zenstruck\SignedUrl\Verifier $verifier */

// REQUEST PASSWORD RESET ACTION (GENERATE URL)
$url = $generator->build('reset_password', ['id' => $user->getId()])
    ->expires('+1 day')
    ->singleUse($user->getPassword()) // current password is the token that changes once "used"
    ->create()
;

// send email to user with $url

// PASSWORD RESET ACTION (VERIFY URL)
try {
    $verifier->verifyCurrentRequest($user->getPassword()); // current password as the token
} catch (\Zenstruck\SignedUrl\Exception\UrlVerificationFailed $e) {
    $this->flashError($e->messageKey());

    return $this->redirect(...);
}

// proceed with the reset, once a new password will be set/saved, this URL will become invalid
```

### Stateless Email Verification

[](#stateless-email-verification)

After a user registers, send a verification email. These emails don't expire but are considered *used* once `$user->isVerified() === true`. Since these links do not expire, you'll likely want some kind of cron job that removes users that haven't verified after a time.

```
final class VerifyToken
{
    public function __construct(private User $user) {}

    public function __toString(): string
    {
        return $this->user->isVerified() ? 'verified' : 'unverified';
    }
}

/** @var \Zenstruck\SignedUrl\Generator $generator */
/** @var \Zenstruck\SignedUrl\Verifier $verifier */

// REGISTRATION CONTROLLER ACTION (GENERATE URL)
$url = $generator->build('verify_user', ['id' => $user->getId()])
    ->singleUse(new VerifyToken($user)) // this token's value will be "unverified"
    ->create()
;

// send email to user with $url

// VERIFICATION ACTION (VERIFY URL)
try {
    $verifier->verifyCurrentRequest(new VerifyToken($user)); // this token's value should be "unverified" but if not, it is invalid
} catch (\Zenstruck\SignedUrl\Exception\UrlVerificationFailed $e) {
    $this->flashError($e->messageKey());

    return $this->redirect(...);
}

$user->verify(); // marks the user as verified and invalidates the URL

// save user & login user immediately or redirect to login page
```

### Stateless Verified Change Email

[](#stateless-verified-change-email)

If your app requires all users have a verified email, a system to allow users to *change* their email requires verification as well. You can use this bundle to enable this in a stateless way. First, when a user requests an email change, send a link to the *new* email. This link includes the *new* email within it so when they click it, the app knows the new *verified* email to set.

```
/** @var \Zenstruck\SignedUrl\Generator $generator */
/** @var \Zenstruck\SignedUrl\Verifier $verifier */

// REQUEST EMAIL CHANGE ACTION (GENERATE URL)
$url = $generator->build('reset_password', ['id' => $user->getId(), 'new-email' => $newEmailRequested])
    ->expires('+1 day')
    ->singleUse($user->getEmail()) // the user's current email
    ->create()
;

// send verification email to $newEmailRequested with $url

// EMAIL CHANGE ACTION (VERIFY URL)
try {
    $verifier->verify($request, $user->getEmail()); // the user's current email
} catch (\Zenstruck\SignedUrl\Exception\UrlVerificationFailed $e) {
    $this->flashError($e->messageKey());

    return $this->redirect(...);
}

$user->setEmail($request->query->get('new-email')); // changes the user email and invalidates the URL

// save user
```

**NOTE:** Since the new email is included in the query string, this could be considered a [PII](https://en.wikipedia.org/wiki/Personal_data) leak (as it will appear in logs). An option to avoid this is to encrypt/decrypt the `new-email` value.

###  Health Score

21

—

LowBetter than 19% of packages

Maintenance10

Infrequent updates — may be unmaintained

Popularity20

Limited adoption so far

Community8

Small or concentrated contributor base

Maturity39

Early-stage or recently created project

 Bus Factor1

Top contributor holds 100% of commits — single point of failure

How is this calculated?**Maintenance (25%)** — Last commit recency, latest release date, and issue-to-star ratio. Uses a 2-year decay window.

**Popularity (30%)** — Total and monthly downloads, GitHub stars, and forks. Logarithmic scaling prevents top-heavy scores.

**Community (15%)** — Contributors, dependents, forks, watchers, and maintainers. Measures real ecosystem engagement.

**Maturity (30%)** — Project age, version count, PHP version support, and release stability.

###  Release Activity

Cadence

Every ~0 days

Total

2

Last Release

1607d ago

### Community

Maintainers

![](https://www.gravatar.com/avatar/707369cc916e0ea1aacbf077dcba464f611cef879f024d8944311a54a15224b3?d=identicon)[kbond](/maintainers/kbond)

---

Top Contributors

[![kbond](https://avatars.githubusercontent.com/u/127811?v=4)](https://github.com/kbond "kbond (59 commits)")

---

Tags

urlsymfonysigned

### Embed Badge

![Health badge](/badges/zenstruck-signed-url-bundle/health.svg)

```
[![Health](https://phpackages.com/badges/zenstruck-signed-url-bundle/health.svg)](https://phpackages.com/packages/zenstruck-signed-url-bundle)
```

###  Alternatives

[winzou/state-machine-bundle

Bundle for the very lightweight yet powerful PHP state machine

34010.4M15](/packages/winzou-state-machine-bundle)[pentatrion/vite-bundle

Vite integration for your Symfony app

2725.3M13](/packages/pentatrion-vite-bundle)[maba/webpack-bundle

Bundle to Integrate Webpack to Symfony

123268.2k4](/packages/maba-webpack-bundle)[jbtronics/settings-bundle

A symfony bundle to easily create typesafe, user-configurable settings for symfony applications

9546.7k2](/packages/jbtronics-settings-bundle)

PHPackages © 2026

[Directory](/)[Categories](/categories)[Trending](/trending)[Changelog](/changelog)[Analyze](/analyze)
