How to sign download URLs with expiration in PHP in WordPress

Contents

Overview: Signed download URLs with expiration in PHP (WordPress)

This article explains, in exhaustive detail, how to create signed download URLs that expire, implement secure verification and serve protected files in a WordPress environment. It covers design choices, data formats, cryptographic details, handling download delivery efficiently (X-Sendfile / X-Accel-Redirect), WordPress integration (rewrite rules and endpoints), security hardening (key storage, timing-safe comparison, key rotation), CDN/cache considerations, and complete code examples you can drop into a plugin or theme.

Why sign download URLs?

  • Prevent direct access: avoid exposing raw filesystem paths or S3 object access without authorization.
  • Limit exposure time: make links valid only for a short window (e.g., one hour).
  • Audit and revoke: combine short expirations with server-side checks to revoke access quickly.
  • CDN-friendly: pre-signed URLs can be used to safely serve through a CDN while still exercising access control.

Design choices and trade-offs

  1. Signature algorithm: use an HMAC (e.g., HMAC-SHA256). HMAC with a server-side secret is simple and secure when the secret is kept private.
  2. What to sign: sign a canonical payload (file id or path expiry optional user or context) to avoid tampering. Prefer file identifiers (IDs) instead of raw paths in URLs to avoid path disclosure and injection issues.
  3. Where to store the secret: store a secret in wp-config.php (constant) or in a secure option protected by file permissions. Avoid storing it in code repositories.
  4. Serving the file: prefer delegating to the webserver using X-Sendfile (Apache) or X-Accel-Redirect (Nginx) for performance and proper range support. Falling back to PHP streaming is possible but slower.
  5. Key rotation: support multiple valid keys (current previous) to rotate keys without invalidating existing short-lived URLs immediately.
  6. Replay and single-use: if you need single-use links, add a server-side token/DB blacklist signed URLs alone only provide expiry-based protection.

Canonical parameters for a signed URL

A recommended minimal set of query parameters:

file File identifier (prefer numeric ID or UUID) or safe encoded path
expires Unix timestamp when URL stops being valid
sig HMAC signature computed over canonical payload
v Signature version (optional, for algorithm/key rotation)

Canonical payload and signing algorithm

Pick a consistent canonical string to sign. For example:

file=file-idexpires=timestampv=1

Compute HMAC-SHA256 over that canonical string using your secret. Encode the raw binary output into URL-safe base64 or hex. Use URL-safe base64 to keep query strings shorter:

// Example helper: URL-safe base64 of binary data
function base64url_encode(data) {
    return rtrim(strtr(base64_encode(data),  /, -_), =)
}

function base64url_decode(data) {
    remainder = strlen(data) % 4
    if (remainder) {
        data .= str_repeat(=, 4 - remainder)
    }
    return base64_decode(strtr(data, -_,  /))
}

Generator: creating a signed URL (PHP example)

This example shows a robust generator. It takes a safe file identifier and expiry, produces a signature using HMAC-SHA256, and returns a full URL.

 file_id,
        expires => expires,
        v => version,
        sig => sig_b64,
    ))
    // Use home_url() in WP, shown here as placeholder:
    url = rtrim(home_url(base_path), /) . ? . params
    return url
}
?>

Verifier: verifying the signature and expiry (PHP)

Use hash_equals() for timing-attack resistant comparison. Always check expiry first. Sanitize and resolve file id to a server path or internal id before serving.

 (int)expires) {
        return new WP_Error(expired, Link has expired.)
    }

    // 2. Recompute signature(s): support multiple keys/versions if needed
    canonical = signed_download_canonical(file_id, expires, v)
    // Expect raw binary HMAC then base64url encode to compare
    expected_raw = hash_hmac(sha256, canonical, SIGNED_DOWNLOAD_SECRET, true)
    expected_b64 = rtrim(strtr(base64_encode(expected_raw),  /, -_), =)

    // 3. Use timing-safe comparison
    if (!function_exists(hash_equals)) {
        // polyfill if needed (PHP < 5.6) – but ensure it is timing safe in production
        valid = (expected_b64 === sig_b64)
    } else {
        valid = hash_equals(expected_b64, sig_b64)
    }

    if (!valid) {
        return new WP_Error(invalid_signature, Signature does not match.)
    }

    return true
}
?>

Mapping file IDs to filesystem paths (avoid raw paths in URLs)

Never allow raw paths from user input. Map file IDs to physical paths stored in a database table or WP postmeta. Example mapping approaches:

  • Store a private_files custom post type its meta contains the path and access rules.
  • Store a DB table mapping integer ID → internal path and MIME type.
  • For S3, map ID → S3 object key and generate S3 pre-signed URL (or proxy through your server).

When resolving, use realpath() and ensure the resolved path is within an allowed base directory to prevent directory traversal.

 real, mime => mime_content_type(real))
}
?>

Serving the file: prefer X-Sendfile / X-Accel-Redirect

Use X-Sendfile (Apache module) or X-Accel-Redirect (Nginx) to let the webserver deliver the file efficiently and support range requests. Your PHP endpoint only verifies the signature and then sets the appropriate header and exits.

Advantages:

  • Low PHP memory usage
  • Correct handling of range requests and caching by the web server
  • Better performance for large files

Apache mod_xsendfile example (PHP)


Nginx X-Accel-Redirect example (PHP)

With Nginx you typically configure an internal location and map the real filesystem path to an internal prefix. In Nginx config:

location /protected_files/ {
    internal
    alias /var/www/example.com/private_uploads/
}

Then in PHP return:


Fallback: PHP streaming (if X-Sendfile is unavailable)

If you must stream from PHP, use a low-memory loop, support range requests if required, and set proper headers (Content-Length, Accept-Ranges). Keep the script execution memory small and use readfile() where possible. Beware of timeouts—use set_time_limit(0) carefully.


Complete WordPress plugin skeleton (register endpoint, generate, verify, serve)

This is a compact plugin scaffold illustrating Rewrite Rule, query var, and template_redirect handler. It validates signature, resolves path, and issues X-Accel-Redirect (adjust for your server).




Acepto donaciones de BAT's mediante el navegador Brave :)



Leave a Reply

Your email address will not be published. Required fields are marked *