Cyril Kato's blog

URL Protection Through HMAC: A Practical Approach

Web applications routinely generate URLs that carry meaning: a link to delete a resource, a password reset token, an invitation to join a workspace. These URLs often encode parameters that, if tampered with, could grant unintended access or trigger unauthorized actions. I found myself repeatedly implementing ad-hoc solutions to this problem, until I settled on a pattern general enough to share.

The approach I describe here uses HMAC (Hash-based Message Authentication Code) to embed cryptographic signatures directly within URLs. The server generates and verifies these signatures; clients simply use the URLs as opaque tokens. This simplicity is the point—by keeping all secrets server-side, we avoid an entire class of implementation mistakes.


The core idea

The protocol rests on a simple asymmetry: only the server knows the secret key. The server generates signed URLs, and the server verifies them. Clients never need to understand or manipulate the signature—they just follow links.

Any modification to a signed URL invalidates its signature. Change a single character in the path or query string, and verification fails. This makes the URLs tamper-proof without requiring any client-side cryptography.


A convention for signature placement

I needed a place to put the signature that would be unambiguous and unlikely to collide with application routes. Drawing inspiration from Unix conventions—where files prefixed with a dot are treated as hidden—I settled on prefixing the signature with a period in the URL path:

https://example.com/.abc123def456/resource/42?action=edit

Here, .abc123def456 is the signature. Everything after it—the path and query parameters—is what gets authenticated. The dot prefix makes it visually distinct and easy to parse programmatically.


Signing a URL

The signing process starts with a URL template containing a placeholder for the signature. I use __SIGNATURE__ as my convention:

https://example.com/__SIGNATURE__/resource/42?action=edit

To compute the signature, I first replace the placeholder with an empty string, creating a base string:

https://example.com//resource/42?action=edit

I then calculate the HMAC-SHA256 of this base string using the server's secret key, encode the result as URL-safe Base64 without padding, and substitute it back into the template with a leading dot:

https://example.com/.abc123def456/resource/42?action=edit

Verifying a URL

Verification reverses the process. When a request arrives, I extract the signature component (the path segment starting with a dot), remove it from the URL to reconstruct the base string, compute what the signature should be, and compare. If they match, the URL is authentic.

One detail matters here: the comparison must be constant-time. A naive string comparison that returns early on the first mismatched character leaks timing information that could help an attacker forge signatures. Most cryptographic libraries provide a secure comparison function for this purpose.


Pseudo-code

For clarity, here is the signing and verification logic in pseudo-code:

function sign_url(url_template, secret_key, placeholder):
    base_string = url_template.replace(placeholder, "")
    hmac_digest = hmac_sha256(secret_key, base_string)
    signature = base64_url_encode(hmac_digest).rstrip("=")
    return url_template.replace(placeholder, "." + signature)

function verify_url(signed_url, secret_key):
    for component in parse_path(signed_url):
        if component.starts_with("."):
            signature = component[1:]
            base_string = signed_url.replace(component, "")
            expected = base64_url_encode(hmac_sha256(secret_key, base_string)).rstrip("=")
            return secure_compare(signature, expected)
    return false

Applications

Generalizing CSRF protection

CSRF tokens traditionally protect form submissions, but the same principle applies to any state-changing URL. A deletion link, an approval action, a settings toggle—all can be protected by signing the URL. The signature serves the same purpose as a CSRF token: proving the link was generated by the server for this specific action.

function generate_delete_link(resource_id):
    template = "/.__TOKEN__/resources/{id}/delete"
    return sign_url(template.replace("{id}", resource_id), SECRET_KEY, "__TOKEN__")

function handle_delete_request(request):
    if verify_url(request.url, SECRET_KEY):
        delete_resource(extract_resource_id(request.url))
        return success_response()
    return unauthorized_response()

Magic links for passwordless authentication

Passwordless login flows often email users a "magic link" that grants access. The security of this pattern depends entirely on the link being unforgeable. By signing the link with an embedded expiration timestamp, I can ensure both authenticity and time-limited validity:

function generate_login_link(user_email):
    expiration = current_time() + LINK_VALIDITY_DURATION
    template = "/.__TOKEN__/login?user={email}&expires={exp}"
    template = template.replace("{email}", url_encode(user_email))
    template = template.replace("{exp}", expiration.to_string())
    return base_url() + sign_url(template, SECRET_KEY, "__TOKEN__")

function handle_magic_link(request):
    if not verify_url(request.url, SECRET_KEY):
        return unauthorized_response()

    expiration = parse_timestamp(extract_parameter(request.url, "expires"))
    if current_time() > expiration:
        return link_expired_response()

    authenticate_user(extract_parameter(request.url, "user"))
    return redirect_to_dashboard()

Secure document sharing

When sharing documents with specific permissions, the signed URL can encode exactly what access is granted. An attacker cannot escalate from read-only to read-write by modifying the URL—any change breaks the signature:

function generate_document_link(document_id, permissions):
    template = "/.__TOKEN__/documents/{id}?permissions={perms}"
    template = template.replace("{id}", document_id)
    template = template.replace("{perms}", encode_permissions(permissions))
    return base_url() + sign_url(template, SECRET_KEY, "__TOKEN__")

Security considerations

The protocol's strength derives entirely from the secret key. It should have at least 256 bits of entropy, be rotated periodically, and never be exposed to clients. Different applications or environments should use different keys.

Signed URLs can be replayed—anyone who obtains a valid URL can use it. For sensitive operations, I recommend including expiration timestamps (verified during validation), single-use nonces for critical actions, or session identifiers that tie the URL to a specific user session.

Transport security matters too. Always use HTTPS. A signed URL intercepted over plaintext HTTP can be replayed by an attacker, and the signature provides no protection against eavesdropping.


Conclusion

This pattern has served me well across several projects. It extends CSRF protection beyond forms, simplifies magic link implementations, and provides a clean way to generate tamper-proof URLs for any purpose.

The approach works because it keeps complexity on the server side. Clients don't need cryptographic capabilities; they just follow links. And because HMAC is well-understood and widely implemented, I can trust the cryptographic foundation without building it myself.

As with any security mechanism, this should be one layer among several. It complements—but does not replace—proper authentication, authorization, and transport security.


References

  1. Krawczyk, H., Bellare, M., & Canetti, R. (1997). HMAC: Keyed-Hashing for Message Authentication. RFC 2104.
  2. OWASP. (2024). Cross-Site Request Forgery Prevention Cheat Sheet. OWASP Cheat Sheet Series.
  3. Stallings, W. (2017). Cryptography and Network Security: Principles and Practice (7th ed.). Pearson.
  4. Ferguson, N., Schneier, B., & Kohno, T. (2010). Cryptography Engineering: Design Principles and Practical Applications. Wiley.