Skip to content

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:

  1. The expiry is not a secret (it's visible in the signed URL anyway).
  2. An attacker cannot forge a valid token with a manipulated expiry because verify() will reject it.
  3. 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-patternRisk
Use === or strcmp() for HMAC comparisonTiming attack — allows forging signatures
Sign only resource_id without expiryTokens are permanent — cannot expire
Sign only expires_at without resource_idOne token grants access to all resources
Use the expiry to distinguish "tampered" vs "expired"Allows oracle attack on HMAC
Embed raw key in the tokenDefeats the purpose — token must be opaque
Long TTLs (days/weeks)Increases exposure window if token is leaked

Released under the MIT License.