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:

  1. key_id, expires and signature must all be present, and key_id must start with rs_pub_. If not: missing_credentials.
  2. key_id must be an active (not revoked) public key. If not: missing_credentials.
  3. expires must be in the future. If not: expired_signature, even if the signature is also wrong.
  4. The account must have an active secret key. If not: missing_credentials.
  5. 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:

  1. Take every query parameter except signature, decoded.
  2. Form-encode each name and value: space becomes +, and every character except A-Z a-z 0-9 - _ . ~ becomes %XX.
  3. Sort the name=value pairs and join them with &.
  4. 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.

  • expired_signature: the signature can be correct, but expires is in the past.
  • Signed URLs for OG images: the full pattern for og:image tags.

Related in this topic

Was this page helpful?