While developing the Snicco project we couldn't find any good standalone PHP-libraries for signing urls. We needed this functionality in a couple of places, so we decided to roll our own implementation.
Features:
- Uses strong, random secrets, generated by a CSPRNG and secure hash functions.
- Validates the signature, the expiration and an enforced usage-limit on a per url basis.
- PSR-7/15 compatible. No hidden dependencies on PHP super globals.
- Protects against timing based side-channel attacks
- Permanently invalidates a signed-url after the max usage. (Rotating your secret invalidates all signed-urls)
- Defensively programmed, making incorrect usage very hard.
- Support for multiple storage backends.
- A properly tested and straightforward API.
While the term signed-url
is technically incorrect (this package uses HMACs, not asymmetric signatures),
we chose to stick to the way Symfony and Laravel name it.
composer require snicco/signed-url
Run the following command from your project root and store the generated secret in a secure location that is outside your web root.
vendor/bin/generate-signed-url-secret
This will output a random, hex-encoded secret that looks like this:
32|1e21be67f2279e485c7c5e8291d05edda7e76ffb01ddb8eb290ce826528ad2ff
This secret should NEVER be stored in version control.
In your application, load the secret from an environment variable in your application using something like symfony/dotenv
.
// require 'vendor/autoload.php';
$secret = \Snicco\Component\SignedUrl\Secret::fromHexEncoded(getenv('SIGNED_URL_SECRET'));
$secret = /* */
$hmac = new Snicco\Component\SignedUrl\HMAC($secret, 'sha256')
// This is a simple interface.
// Use one of the inbuilt storages in the #storages section or provide your own.
$storage = /* */
$signer = new \Snicco\Component\SignedUrl\UrlSigner($storage, $hmac);
// The maximum lifetime in seconds that this link should be valid for.
$lifetime_in_sec = 60;
// The maximum amount that this link should be valid for.
// After each successfully validation this amount will be decreased by 1.
$usage_limit = 1;
// optional: adding request context that must be the same in order to
// successfully validate a signed-url.
$context = ($_SERVER['REMOTE_ADDR'] ?? '') . ($_SERVER['HTTP_USER_AGENT'] ?? '');
$signed_url = $signer->sign('https://example.com/unsubscribe?user_id=12' , $lifetime_in_sec, $usage_limit, $context);
$mailer = /* */
$href = $signed_url->asString();
// $href will be something like transformers:
// https://example.com/unsubscribe?user_id=12expires=1639783661&signature=Del1cGmLB1wVET6PJieCrQ==|1MTBBGIpEGPVuGaKDjjrHDBusMNoWB15Ng5lKBSSLQY=
$mailer->send('[email protected]', "Click <a href='{{$href}}'> here <a/> to unsubscribe.")
Validation of signed-urls should be performed in a middleware to avoid boilerplate.
The code samples below describe the manual way to validate urls in any PHP application.
If your favorite framework is PSR-7/PSR-15 compatible and supports middleware on a per-route basis, you can use our PSR-15 middleware bridge which makes this dead simple.
$storage = /* */
$hmac = /* */
// Clean expired links periodically.
try {
// 0-100
$percentage = 2;
\Snicco\Component\SignedUrl\GarbageCollector::clean($storage, $percentage);
} catch (UnavailableStorage $e) {
// gc did not work for some reason. Log and continue.
error_log($e->getMessage());
}
$validator = new \Snicco\Component\SignedUrl\SignedUrlValidator($storage, $hmac);
$target = $_SERVER['REQUEST_URI'].'?'.$_SERVER['QUERY_STRING'];
try {
// optional context, has to be the same scheme used at creation.
$context = ($_SERVER['REMOTE_ADDR'] ?? '') . ($_SERVER['HTTP_USER_AGENT'] ?? '');
$validator->validate( $target, $context);
} catch (\Snicco\Component\SignedUrl\Exception\InvalidSignature $e ) {
error_log("invalid signature.");
echo "This link has expired. Please request a new one."
} catch (\Snicco\Component\SignedUrl\Exception\SignedUrlExpired $e ) {
error_log("signed url expired.");
echo "This link has expired. Please request a new one."
} catch (\Snicco\Component\SignedUrl\Exception\SignedUrlUsageExceeded $e ) {
error_log("signed url usage exceeded.");
echo "This link has expired. Please request a new one."
}
// Everything is valid.
// If the link can be used multiple times the usage is decremented automatically by 1.
echo "You have been unsubscribed."
The Snicco\SignedUrl\Contracts\SingedUrlStorage
keeps an identifier
for each signed-url that is created and ensures that your max usage limits are enforced.
Without some form of backend storage, signed-urls are valid any number of times until the expiration
timestamp is passed. (If this is what you want you can use the NullStorage
).
The SessionStorage
accepts an array
or any object that implements
ArrayAccess
(passed by reference).
// using an array.
$storage = new \Snicco\Component\SignedUrl\Storage\SessionStorage($_SESSION);
// using an object implementing ArrayAccess
$arr = new MyArrayAccess();
$storage = new \Snicco\Component\SignedUrl\Storage\SessionStorage($arr);
The NullStorage
does nothing. No signed-urls will be stored
and no usage limits are enforced. Use this only if your signed-urls should be valid any number of times before expiring.
Validity of a signed-url will be based solely on the correct signature and expriation timestamp.
You can use the InMemoryStorage
during unit tests.
$storage = new \Snicco\Component\SignedUrl\Storage\InMemoryStorage()
We have a dedicated PSR-16 bridge that will allow you to use any PSR-16 cache as a storage.
Implementing your own storage is very easy.
You only have to implement the simple SingedUrlStorage
interface.
Use the snicco/signed-url-testing
package
to test your implementation against the contract of the interface.
This repository is a read-only split of the development repo of the Snicco project.
This is how you can contribute.
Please report issues in the Snicco monorepo.
If you discover a security vulnerability, please follow our disclosure procedure.