Signed URLs
Signed URLs provide time-limited, resource-scoped access to protected resources without requiring the caller to authenticate with an account. The pattern is used for file downloads, presigned upload slots, and any case where you need to share temporary access with a third party.
Core concept
A signed URL contains everything needed to authorize access: the resource ID, expiry time, and an HMAC signature that proves the URL was generated by a trusted server. The server needs only its secret key to verify — no database lookup required.
Token format
base64url({resource_id}|{expires_at}|{hmac-sha256(resource_id|expires_at, secret)})The HMAC covers resource_id|expires_at together. Changing either part invalidates the signature. This binds the token to exactly one resource and one expiry window.
Signer implementation
php
final readonly class HmacSigner
{
private const string ALGO = 'sha256';
public function __construct(
private string $secret,
) {}
public function sign(int $resourceId, string $expiresAt): string
{
$payload = $resourceId . '|' . $expiresAt;
$mac = hash_hmac(self::ALGO, $payload, $this->secret);
return $this->base64UrlEncode($payload . '|' . $mac);
}
public function verify(string $token, string $now): ?int
{
$decoded = $this->base64UrlDecode($token);
if ($decoded === null) {
return null;
}
$parts = explode('|', $decoded, 3);
if (count($parts) !== 3) {
return null;
}
[$resourceId, $expiresAt, $storedMac] = $parts;
$expectedMac = hash_hmac(self::ALGO, $resourceId . '|' . $expiresAt, $this->secret);
// hash_equals() is mandatory — using === leaks timing information
if (!hash_equals($expectedMac, $storedMac)) {
return null;
}
if ($expiresAt < $now) {
return null;
}
return (int) $resourceId;
}
}hash_equals() is non-negotiable. A string-equality comparison exits on first mismatch, leaking how many characters of the HMAC match. An attacker can exploit this to forge signatures byte by byte. hash_equals() always compares all characters.
410 Gone vs 401 Unauthorized for expired tokens
Users benefit from knowing whether their link expired (and they should request a new one) versus whether the link was never valid. The signer verifies HMAC first, then expiry. To distinguish them in the HTTP response:
php
$resourceId = $this->signer->verify($token, $now);
if ($resourceId === null) {
// Extract expiry without HMAC verification
$expiresAt = $this->signer->extractExpiresAt($token);
if ($expiresAt !== null && $expiresAt < $now) {
return $problems->create($request, 'gone', 'This link has expired.', 410, '');
}
return $problems->create($request, 'unauthorized', 'Invalid or expired token.', 401, '');
}extractExpiresAt() only decodes base64 and splits on | — it does NOT verify the HMAC. This is safe because:
- The expiry is not a secret (it's visible in the signed URL anyway).
- An attacker cannot forge a valid token with a manipulated expiry because
verify()will reject it. - The 410 response provides no information that helps forge tokens.
Do NOT expose different error messages for "HMAC mismatch" vs "expiry past" — that would allow an attacker to construct valid signatures for arbitrary expiry values first, then use them to probe timing.
Generating signed URLs
php
// POST /files/{id}/sign
$expiresAt = (new \DateTimeImmutable())
->add(new \DateInterval("PT{$ttlSeconds}S"))
->format('Y-m-d H:i:s');
$token = $this->signer->sign($file->id, $expiresAt);
return $json->create([
'token' => $token,
'expires_at' => $expiresAt,
'ttl_seconds' => $ttlSeconds,
'url' => '/download?token=' . urlencode($token),
]);Always urlencode() the token before embedding in URLs — base64url characters are URL-safe but the = padding (if present) is not, and the | separator in the decoded payload must not appear in the encoded form.
Secret key management
- Inject the secret from an environment variable — never hardcode it.
- Use at least 32 bytes of random data (
random_bytes(32)→ hex or base64). - For secret rotation, support verifying against multiple secrets simultaneously (try each until one succeeds), then phase out the old secret.
php
// Multi-secret support during rotation
public function verifyWithRotation(string $token, string $now, array $secrets): ?int
{
foreach ($secrets as $secret) {
$signer = new HmacSigner($secret);
$id = $signer->verify($token, $now);
if ($id !== null) {
return $id;
}
}
return null;
}Stateless vs. stateful signed URLs
This pattern is stateless — the server doesn't track issued tokens. This is the main advantage (no DB lookup on every download), but it means:
- You cannot revoke a signed URL before it expires.
- If the secret is rotated, all previously issued tokens are immediately invalidated.
For revocable tokens, maintain a blocklist table (revoked_tokens) and check it during verification. This trades the stateless benefit for revocability.
What NOT to do
| Anti-pattern | Risk |
|---|---|
Use === or strcmp() for HMAC comparison | Timing attack — allows forging signatures |
Sign only resource_id without expiry | Tokens are permanent — cannot expire |
Sign only expires_at without resource_id | One token grants access to all resources |
| Use the expiry to distinguish "tampered" vs "expired" | Allows oracle attack on HMAC |
| Embed raw key in the token | Defeats the purpose — token must be opaque |
| Long TTLs (days/weeks) | Increases exposure window if token is leaked |