invalid_signature
invalid_signature means the API received a signed URL, but the signature parameter does not match the one the API calculates from the rest of the URL. The API does not render anything. The URL will fail on every retry, so fix the signer or the URL.
| Field | Value |
|---|---|
| HTTP status | 401 Unauthorized |
error.type |
authentication_error |
error.code |
invalid_signature |
retryable |
false |
| Credit used | No |
This page belongs to the error code reference. For the signed URL format, see signed URLs.
Example response
{ "error": { "type": "authentication_error", "code": "invalid_signature", "message": "Signature verification failed", "retryable": false, "docs": "https://docs.renderscreenshot.com/errors/invalid_signature" }, "request_id": "req_c94e1a7d03b28f65" }
When you get this code, and when you get another
The API checks a signed URL in this order. Only a failure at step 5 gives invalid_signature:
key_id,expiresandsignaturemust all be present, andkey_idmust start withrs_pub_. If not: missing_credentials.key_idmust be an active (not revoked) public key. If not:missing_credentials.expiresmust be in the future. If not: expired_signature, even if the signature is also wrong.- The account must have an active secret key. If not:
missing_credentials. - The signature must match. If not:
invalid_signature.
So invalid_signature tells you that the key pair is fine and the URL has not expired. The problem is the signed string or the secret.
How the API rebuilds the signed string
The API does not sign the raw query string that you sent. It decodes the query, then encodes it again in one fixed way:
- Take every query parameter except
signature, decoded. - Form-encode each name and value: space becomes
+, and every character exceptA-Z a-z 0-9 - _ . ~becomes%XX. - Sort the
name=valuepairs and join them with&. - Calculate HMAC-SHA256 with your
rs_secret_key, as lowercase hex.
Because the API decodes first, two URLs that encode the same values differently get the same canonical string. But a character that changes meaning when decoded changes the string. In our test, url=https://example.com/a%20b?x=1+2 sent unencoded was decoded as x=1 2, not x=1+2, and the signature failed.
Common causes
| Cause | What happens |
|---|---|
Target URL not percent-encoded inside the url parameter |
+ becomes a space, and & splits the target into two parameters |
Signer sorts names but does not encode, or encodes with URLSearchParams |
Values with *, ', ( or spaces do not match |
A parameter added after signing (utm_source, a cache buster, a framework's own parameter) |
The API includes it in the string; your signer did not |
rs_secret_ key from another account, or an old secret after a rotation |
Every URL fails |
Signing with the rs_live_ key instead of the rs_secret_ key |
Every URL fails |
expires changed after signing (for example rounded by a template) |
Every URL fails |
Find the difference
This script rebuilds the canonical string the same way as the API, and compares signatures. Run it on your server with one failing URL. It prints only the first 12 characters of each signature:
# check_signature.py (Python 3.9+, standard library only) # Usage: RS_SECRET_KEY=rs_secret_... python3 check_signature.py '<signed url>' import hashlib import hmac import os import sys import time from urllib.parse import parse_qsl, quote_plus, urlsplit def canonical_string(signed_url): query = urlsplit(signed_url).query params = dict(parse_qsl(query, keep_blank_values=True)) # decoded; last value wins params.pop("signature", None) pairs = [f"{quote_plus(k, safe='')}={quote_plus(v, safe='')}" for k, v in params.items()] return "&".join(sorted(pairs)), params def main(): signed_url = sys.argv[1] secret = os.environ["RS_SECRET_KEY"] canonical, params = canonical_string(signed_url) sent = dict(parse_qsl(urlsplit(signed_url).query)).get("signature", "") expected = hmac.new(secret.encode(), canonical.encode(), hashlib.sha256).hexdigest() print("key_id :", params.get("key_id", "(missing)")) expires = int(params.get("expires", "0") or 0) print("expires :", expires, "(in the past)" if expires < time.time() else "(ok)") print("canonical :", canonical) print("signature :", "MATCH" if hmac.compare_digest(sent, expected) else "MISMATCH") if sent != expected: print("expected :", expected[:12] + "...") print("sent :", (sent or "(missing)")[:12] + "...") sys.exit(1) if __name__ == "__main__": main()
Compare the canonical line with the string your signer hashed. The first character that differs is the bug. For working signers in Node and Python, see the publishable keys guide.
Test your signer in CI
A signed URL usually fails in an <img> tag or an og:image crawler, where nobody sees the JSON error. Add a check that requests one freshly signed URL on each deploy:
// smoke-signed-url.mjs (Node 22): fail CI when a freshly signed URL is rejected. // Usage: node smoke-signed-url.mjs "$(your-signer --print-url)" const signedUrl = process.argv[2]; if (!signedUrl) { console.error('Usage: node smoke-signed-url.mjs <signed-url>'); process.exit(2); } const res = await fetch(signedUrl, { signal: AbortSignal.timeout(90_000) }); const type = res.headers.get('content-type') ?? ''; if (res.ok && type.startsWith('image/')) { console.log(`OK: HTTP ${res.status}, ${type}`); } else { const body = type.includes('json') ? await res.json() : null; const code = body?.error?.code ?? `HTTP ${res.status}`; const hints = { invalid_signature: 'The canonical string or the secret differs from what the API uses.', expired_signature: 'expires is in the past: check the TTL and the server clock.', missing_credentials: 'key_id, expires or signature is missing, or key_id is not an active rs_pub_ key.', }; console.error(`FAIL: ${code}. ${hints[code] ?? body?.error?.message ?? ''}`); process.exitCode = 1; }
A successful check renders one screenshot and uses one credit. Use a target URL that contains a space and a +, so the check also covers encoding.
Tested on 2026-10-15. The checker ran with Python 3.14, and its canonical strings were identical to those from the API's own Ruby code for the same three URLs. The CI check ran with Node 22.18 against a local mock that returns the API's error envelope.
Related
- expired_signature: the signature can be correct, but
expiresis in the past. - Signed URLs for OG images: the full pattern for
og:imagetags.