Signed URL Generator With HMAC
The 60-line HMAC-signed URL helper I use for download links and webhook callbacks. Stdlib only, constant-time verification, expiry baked in, and no S3 dependency to debug at 2 a.m.
By @samirakumar
December 15, 2025
·
Updated August 18, 2026
605 views
8
4.5 (9)
from __future__ import annotations
import base64
import hashlib
import hmac
import time
from urllib.parse import urlencode
SECRET = b'replace-me-with-32-random-bytes'
def sign_url(path: str, expires_in: int = 300, secret: bytes = SECRET) -> str:
expires_at = int(time.time()) + expires_in
base = f'{path}?expires={expires_at}'
sig = hmac.new(secret, base.encode('utf-8'), hashlib.sha256).digest()
sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b'=').decode('ascii')
return f'{base}&sig={sig_b64}'
if __name__ == '__main__':
print(sign_url('/files/report.pdf'))
print(sign_url('/webhooks/payment/abc123', expires_in=60))The whole signed-URL pattern reduces to two ideas: pick an expires timestamp, then HMAC the canonical string path?expires=<ts>. I use urlsafe_b64encode and strip the trailing = padding so the signature drops cleanly into a query parameter without re-escaping. The secret is 32 random bytes generated once and stored in your secrets manager; never derive it from a username, the URL, or anything an attacker can replay. Including expires in the signed string is non-negotiable because it is the only thing stopping someone who saw one URL from minting a permanent one.
from __future__ import annotations
import base64
import hashlib
import hmac
import time
from urllib.parse import urlparse, parse_qs
SECRET = b'replace-me-with-32-random-bytes'
def verify_signed_url(url: str, secret: bytes = SECRET, now: float = None) -> dict:
parsed = urlparse(url)
qs = parse_qs(parsed.query, keep_blank_values=True)
sig = qs.pop('sig', [None])[0]
expires_str = qs.get('expires', [None])[0]
if not sig or not expires_str:
return {'ok': False, 'reason': 'missing_signature'}
try:
expires = int(expires_str)
except ValueError:
return {'ok': False, 'reason': 'malformed_expires'}
current = time.time() if now is None else now
if current > expires:
return {'ok': False, 'reason': 'expired'}
base = f'{parsed.path}?expires={expires}'
expected = hmac.new(secret, base.encode('utf-8'), hashlib.sha256).digest()
expected_b64 = base64.urlsafe_b64encode(expected).rstrip(b'=').decode('ascii')
if not hmac.compare_digest(expected_b64, sig):
return {'ok': False, 'reason': 'bad_signature'}
return {'ok': True, 'path': parsed.path, 'expires_at': expires}
if __name__ == '__main__':
# Sign a URL inline so this stage is standalone.
expires_at = int(time.time()) + 300
base = '/files/report.pdf' + f'?expires={expires_at}'
sig = hmac.new(SECRET, base.encode('utf-8'), hashlib.sha256).digest()
sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b'=').decode('ascii')
good = f'{base}&sig={sig_b64}'
print('good URL:', verify_signed_url(good))
# Tamper with the path.
tampered = good.replace('report.pdf', 'admin.pdf')
print('tampered:', verify_signed_url(tampered))
# Already expired.
print('expired:', verify_signed_url(good, now=expires_at + 1))hmac.compare_digest is the part you cannot skip. A naive expected == sig comparison short-circuits on the first byte mismatch, and an attacker who can time your responses can recover the signature byte by byte across millions of requests. The verifier rebuilds the canonical string from parsed.path and the expires query param so a re-ordered URL still verifies (the canonical string is path plus ?expires=N, never the raw query). I return a tagged dict instead of a bool so the caller can log reason for ops without leaking which check failed back to the user, who only ever sees a generic 403.
from __future__ import annotations
import base64
import hashlib
import hmac
import time
from urllib.parse import urlparse, parse_qs
SECRET = b'replace-me-with-32-random-bytes'
def sign(method: str, path: str, expires_in: int = 300, secret: bytes = SECRET) -> str:
method = method.upper()
expires_at = int(time.time()) + expires_in
canonical = f'{method}\n{path}\nexpires={expires_at}'
sig = hmac.new(secret, canonical.encode('utf-8'), hashlib.sha256).digest()
sig_b64 = base64.urlsafe_b64encode(sig).rstrip(b'=').decode('ascii')
return f'{path}?expires={expires_at}&sig={sig_b64}'
def verify(method: str, url: str, secret: bytes = SECRET, now=None) -> dict:
method = method.upper()
parsed = urlparse(url)
qs = parse_qs(parsed.query, keep_blank_values=True)
sig = qs.get('sig', [None])[0]
expires_str = qs.get('expires', [None])[0]
if not sig or not expires_str:
return {'ok': False, 'reason': 'missing'}
expires = int(expires_str)
current = time.time() if now is None else now
if current > expires:
return {'ok': False, 'reason': 'expired'}
canonical = f'{method}\n{parsed.path}\nexpires={expires}'
expected = base64.urlsafe_b64encode(
hmac.new(secret, canonical.encode('utf-8'), hashlib.sha256).digest()
).rstrip(b'=').decode('ascii')
if not hmac.compare_digest(expected, sig):
return {'ok': False, 'reason': 'bad_sig'}
return {'ok': True}
if __name__ == '__main__':
download_url = sign('GET', '/files/report.pdf')
print('GET via GET signature:', verify('GET', download_url))
print('POST replay attempt:', verify('POST', download_url))
callback_url = sign('POST', '/webhooks/job/42', expires_in=120)
print('POST callback:', verify('POST', callback_url))Binding the HTTP method into the canonical string costs three characters and stops a whole class of mistake: an attacker who scraped a download URL out of an email cannot replay it as a DELETE against the same path. The canonical format uses newline separators (the same shape AWS SigV4 uses) so re-ordering query params cannot collide with a different signature input. I keep the format simple, two fields plus the path, because every extra slot is one more thing your verifier has to canonicalize identically on both sides. When that drifts, you get the worst kind of bug: signatures match in the unit test and fail in production because the load balancer stripped a trailing slash.
