QRQR Code Agency
Concepts

Security model

How QR Code Agency protects your keys, your callers, and your dynamic destinations. Hashing, SSRF, signing, redirect cache headers.

Security model

A QR API touches every layer where attackers tend to look: bearer secrets, server-side fetches, redirects, webhooks, payment data. This page documents what we do and what you should do at each layer.

API key storage

API keys are bearer secrets. The raw key is shown once at creation and never again. We persist only the SHA-256 hash on a unique-indexed column.

PropertyValue
AlgorithmSHA-256 (no salt: keys are 36 random URL-safe chars, brute-force is not viable)
Storage columnQrApiKey.key_hash, unique index
RecoveryImpossible. Lose the raw key, revoke and create a new one.
RevocationImmediate. Set is_active=False and the next request returns 401.

SSRF protection on fetched URLs

Several fields ask us to fetch something on your behalf:

  • logo_url
  • bg_image_url
  • Webhook delivery (your URL is the target)

Every outbound fetch goes through safe_get(url), which:

  1. Resolves the hostname to an IP.
  2. Refuses loopback (127.0.0.0/8, ::1).
  3. Refuses private networks (10/8, 172.16/12, 192.168/16, fc00::/7).
  4. Refuses link-local (169.254/16, including AWS / GCP / Azure metadata).
  5. Refuses multicast and reserved ranges.
  6. Caps response size to 5 MB and timeout to 5 seconds.
  7. Validates the content type (must be a recognized image MIME for logo_url / bg_image_url).

DNS rebinding is mitigated: we resolve, validate, and connect to the resolved IP directly, ignoring later DNS lookups.

A blocked URL returns:

{
 "logo_url": "refusing to fetch internal-host: resolves to private/internal IP 10.0.0.5"
}

There is no way to bypass this check. Internal-only logos must be hosted on a public CDN.

Webhook signing

Every webhook delivery carries an HMAC-SHA256 signature in the X-QRStudio-Signature header:

X-QRStudio-Signature: t=1746644400,v1=4f7c2a...
  • t is the Unix timestamp of the dispatch
  • v1 is HMAC_SHA256(secret, f"{t}.{request_body}") hex-encoded

To verify:

import hmac, hashlib

def verify(secret: str, body: bytes, header: str) -> bool:
 parts = dict(p.split("=", 1) for p in header.split(","))
 t, v1 = parts["t"], parts["v1"]
 expected = hmac.new(
 secret.encode(),
 f"{t}.{body.decode()}".encode(),
 hashlib.sha256,
 ).hexdigest()
 return hmac.compare_digest(v1, expected)

Use hmac.compare_digest (or your language's constant-time equivalent), never ==.

We also recommend rejecting any delivery older than 5 minutes by checking t against your server clock. This prevents replay attacks on a leaked signature.

The webhook secret is shown once at creation. Rotate it via POST /api/v1/webhooks/<id>/rotate/; the old secret is invalidated immediately.

Stripe webhook signing

The Stripe webhook at POST /api/billing/webhook/ is a separate endpoint. The Stripe signature (Stripe-Signature header) is the auth: there is no API key, no JWT, no session cookie. We verify against STRIPE_WEBHOOK_SECRET and refuse anything that does not match.

Idempotency is mandatory: every event id is recorded in StripeWebhookEvent and skipped if already processed. Stripe retries aggressively; double-processing a checkout.session.completed would corrupt plan state.

Dynamic redirect

Every scan of a dynamic QR hits https://q.qrstudio.agency/q/<short_id>/. We:

  1. Look up the active DynamicQr row by short_id.
  2. Append a DynamicQrScan audit row with timestamp, country (from CF-IPCountry), referer, user agent, etc.
  3. 302 redirect to destination_url.

The 302 response carries:

HTTP/1.1 302 Found
Location: https://example.com/menus/spring-2026
Cache-Control: no-store

Cache-Control: no-store is non-negotiable. The whole pitch is the destination can change at any time; a CDN or browser caching the 302 would defeat the feature.

Country detection

Country code is captured from the request edge:

  1. Cloudflare CF-IPCountry (when behind Cloudflare, our default production setup).
  2. Vercel x-vercel-ip-country (preserved when traffic transits Vercel).
  3. Sentinel values XX and T1 (Tor exit, anonymous proxy) are dropped to keep analytics clean.

We do not run a GeoIP lookup ourselves. The edge is correct, fast, and free.

TLS

  • Production endpoints are HTTPS only with TLS 1.2+ (TLS 1.3 preferred).
  • HSTS is set with max-age=63072000; includeSubDomains; preload.
  • We do not accept HTTP redirects to HTTPS for /api/*. The HTTP request gets a 426 Upgrade Required.
  • localhost development is allowed on plain HTTP for convenience.

Payments and PII

  • We never store card numbers. Stripe holds the PAN; we hold the Stripe customer id and subscription id.
  • We never log API keys, raw bodies of /generate/ calls, or webhook secrets.
  • We log the prefix of API keys (smk_aBc12dEf) for audit, never the full string.
  • Email addresses are only stored for accounts; we do not collect any PII about scanners. We capture country and coarse user-agent class, not raw IPs.

Reporting a security issue

Email security@qrstudio.agency. PGP key on the website. We follow a 90 day disclosure window with credit to the reporter unless requested otherwise.

What is next

On this page