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.
| Property | Value |
|---|---|
| Algorithm | SHA-256 (no salt: keys are 36 random URL-safe chars, brute-force is not viable) |
| Storage column | QrApiKey.key_hash, unique index |
| Recovery | Impossible. Lose the raw key, revoke and create a new one. |
| Revocation | Immediate. 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_urlbg_image_url- Webhook delivery (your URL is the target)
Every outbound fetch goes through safe_get(url), which:
- Resolves the hostname to an IP.
- Refuses loopback (
127.0.0.0/8,::1). - Refuses private networks (
10/8,172.16/12,192.168/16,fc00::/7). - Refuses link-local (
169.254/16, including AWS / GCP / Azure metadata). - Refuses multicast and reserved ranges.
- Caps response size to 5 MB and timeout to 5 seconds.
- 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...tis the Unix timestamp of the dispatchv1isHMAC_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:
- Look up the active
DynamicQrrow byshort_id. - Append a
DynamicQrScanaudit row with timestamp, country (fromCF-IPCountry), referer, user agent, etc. - 302 redirect to
destination_url.
The 302 response carries:
HTTP/1.1 302 Found
Location: https://example.com/menus/spring-2026
Cache-Control: no-storeCache-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:
- Cloudflare
CF-IPCountry(when behind Cloudflare, our default production setup). - Vercel
x-vercel-ip-country(preserved when traffic transits Vercel). - Sentinel values
XXandT1(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 a426 Upgrade Required. localhostdevelopment 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
- Reliability and SLA: uptime guarantees and the status page
- API reference: webhooks
- Authentication: rotating keys safely