Skip to content

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

use Nene\Kit\SignedUrl;

$signer = new SignedUrl($_ENV['SIGNED_URL_SECRET'], defaultTtl: 3600);
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):

$secret = \Nene\Kit\SignedUrl::generateSecret();
// → "a3f1b2c4..." (64 hex characters)

Store the secret in .env:

SIGNED_URL_SECRET=a3f1b2c4...

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):

  1. After the first successful use, record the URL's signature in a used_tokens table.
  2. On subsequent requests, check the table before calling requireValid(). If the signature is already recorded, return 410 or 409.
  3. 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.

  • class/kit/SignedUrl.php — implementation.
  • tests/Unit/Kit/SignedUrlTest.php — unit tests (15 tests).
  • config/error_codes.phpSIGNED-URL-EXPIRED (410) and SIGNED-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.