Signed URLs¶
Time-limited, tamper-evident access tokens embedded directly in URLs. Generated by Nene\Kit\SignedUrl.
When to use signed URLs¶
| Use case | Typical TTL | Notes |
|---|---|---|
| File / invoice download links | 1 hour | Short-lived to reduce unauthorized forwarding |
| Email confirmation links | 24 hours | User must confirm before link expires |
| Password-reset links | 1 hour | Short window to minimize brute-force exposure |
| Admin invitation links | 72 hours | Long enough for the recipient to act |
| One-time payment receipts | 30 days | Use replay-protection log if one-time enforcement is required |
Signed URLs let a resource be accessed without session authentication — the URL itself is the credential. They are ideal for links sent over email or SMS where the recipient may not be logged in.
API reference¶
| Method | Signature | Description |
|---|---|---|
sign |
sign(string $path, array $params = [], ?int $ttl = null, ?int $now = null): string |
Returns a URL with expires and signature appended to the query string. |
verify |
verify(string $url, ?int $now = null): bool |
Returns true if the URL is unexpired and the signature matches. Never throws. |
requireValid |
requireValid(string $url, ?int $now = null): void |
Throws HttpTermination(410) for expired URLs and HttpTermination(403) for invalid ones. Use in controllers. |
generateSecret |
static generateSecret(): string |
Returns a 64-char hex string (32 bytes, 256 bits of entropy). |
sign()¶
$url = $signer->sign('/download/invoice', ['id' => 42]);
// → /download/invoice?expires=1716858000&id=42&signature=<64-char-hex>
Parameters are sorted by key before signing so the signature is key-order-independent. The signature parameter is always appended last (after sorting) and is not itself sorted into the payload.
verify()¶
Returns false (never throws) if:
- expires or signature parameters are missing.
- expires is in the past.
- The recomputed HMAC-SHA256 does not match (timing-safe hash_equals comparison).
requireValid()¶
Calls verify() internally. Use as a controller guard before serving the protected resource.
Error codes thrown:
- SIGNED-URL-EXPIRED → HTTP 410 Gone
- SIGNED-URL-INVALID → HTTP 403 Forbidden
Controller guard pattern¶
// class/controller/DownloadController.php
public function getAction(): never
{
$signer = new SignedUrl($_ENV['SIGNED_URL_SECRET']);
$signer->requireValid($_SERVER['REQUEST_URI']);
// URL is valid and unexpired — serve the resource
$id = (int) $this->request->get('id');
$file = $this->invoiceService->findById($id);
JsonResponder::outputArray(['url' => $file->storagePath()]);
}
requireValid() throws HttpTermination if the URL is expired or invalid. The framework's front controller catches it and emits the JSON error response automatically — no explicit error handling is needed in the action.
Generation pattern (email confirmation flow)¶
// In a registration or invitation service
public function sendConfirmationEmail(string $email, int $userId): void
{
$signer = new SignedUrl($_ENV['SIGNED_URL_SECRET'], defaultTtl: 86400); // 24 hours
$confirmUrl = 'https://example.com' . $signer->sign(
'/email/confirm',
['user_id' => $userId, 'email' => $email],
);
$this->mailer->send(
to: $email,
subject: 'Please confirm your email address',
body: "Click here to confirm: {$confirmUrl}",
);
}
The receiving controller:
public function getAction(): never
{
$signer = new SignedUrl($_ENV['SIGNED_URL_SECRET'], defaultTtl: 86400);
$signer->requireValid($_SERVER['REQUEST_URI']);
$userId = (int) $this->request->get('user_id');
$this->userService->markEmailConfirmed($userId);
JsonResponder::outputArray(['status' => 'confirmed']);
}
Generating a secret¶
Run once to generate a suitable secret (256-bit entropy):
Store the secret in .env:
Do not hard-code the secret in source files. Rotate the secret to invalidate all outstanding signed URLs.
Security notes¶
Secret length¶
Use at least 256 bits (32 bytes). SignedUrl::generateSecret() produces exactly 32 bytes of random_bytes() output, encoded as a 64-character hex string. Do not use a human-readable passphrase as the secret.
TTL guidance¶
| Scenario | Recommended TTL |
|---|---|
| Temporary download | 1 hour (3600 s) |
| Email confirmation | 24 hours (86400 s) |
| Password reset | 1 hour (3600 s) |
| Admin invitation | 72 hours (259200 s) |
Shorter TTLs reduce the window of exposure if a link is intercepted or forwarded. Longer TTLs improve usability for users who delay acting on an email.
Replay protection¶
Signed URLs are valid until their expires timestamp — the same URL may be used multiple times within the validity window. For strictly one-time use (e.g., email confirmation that must only succeed once):
- After the first successful use, record the URL's
signaturein aused_tokenstable. - On subsequent requests, check the table before calling
requireValid(). If the signature is already recorded, return 410 or 409. - Clean up expired tokens periodically (e.g.,
DELETE FROM used_tokens WHERE used_at < NOW() - INTERVAL 7 DAY).
NeNe does not ship a built-in one-time-use enforcement layer — this is intentionally left to the application because the storage backend and cleanup strategy differ per project.
What "expires" means¶
The expires parameter is a Unix timestamp (seconds since epoch). The comparison uses time() on the server. Signed URLs do not account for client clock skew — if you need to tolerate small skew, add a grace period to the TTL (e.g., add 30 seconds).
HMAC algorithm¶
HMAC-SHA256 is used. The payload is path?sorted_query_params where parameters are sorted by key. Sorting ensures that parameter order does not affect validity.
Related files¶
class/kit/SignedUrl.php— implementation.tests/Unit/Kit/SignedUrlTest.php— unit tests (15 tests).config/error_codes.php—SIGNED-URL-EXPIRED(410) andSIGNED-URL-INVALID(403).docs/development/error-codes.md— error code catalog.docs/development/invitation-tokens.md— related pattern for invitation tokens.docs/field-trials/2026-05-field-trial-42.md— FT42 field trial report.