Hotlink Protection
Picora lets you control who can hot-link your images via two complementary mechanisms:
- Referer whitelist — per-image list of host patterns; non-matching
Refererheaders are blocked atimage-servebefore any bytes leave the CDN edge. - Signed URLs — tokenised URLs with HMAC + expiry. The only way to access private images, and an opt-in for paywalled public images.
Referer whitelist
Configure the list in the picora-center “Hotlink protection” dialog or via PATCH /v1/images/:id with the allowedReferers field.
PATCH /v1/images/abc123def45{ "allowedReferers": [ "self", // any picora.me / picora.cn / web.moraya.app domain "blog.example.com", // exact match "*.shop.example.com" // any subdomain (a.shop.example.com, x.y.shop.example.com) ]}Pattern semantics
| Pattern | Matches |
|---|---|
self | All Picora-owned domains (picora.me / picora.cn / web.moraya.app, plus subdomains) |
blog.example.com | Exact host (case-insensitive). Does not match sub.blog.example.com |
*.example.com | Any subdomain (including multi-level). Does not match example.com itself |
example.com | Root domain only; does not match subdomains |
Mid-string wildcards like sub.*.com are rejected at write time.
Three states
allowedReferers value | Meaning |
|---|---|
null | No restriction (default). Public images are still gated by is_public. |
[] (empty array) | Deny everyone. Only signed URLs can fetch the image. |
[...] | Allow listed patterns; deny others. |
Behaviour without a Referer header
When the allowedReferers list is non-empty and the request arrives without a Referer header (curl, server-side fetch, some privacy-extension users), the request is denied. There is no opt-out — wide-open hot-link prevention is the entire point.
If you want to permit such requests, generate signed URLs and skip the whitelist for that user.
Signed URLs
POST /v1/images/abc123def45/signContent-Type: application/json
{ "expSeconds": 3600 }Response:
{ "success": true, "data": { "signedUrl": "https://media.picora.me/abc123.png?sig=eyJ...&exp=1717392000", "expiresAt": "2026-05-08T11:00:00.000Z", "exp": 1717392000 }}The sig value is base64url(HMAC-SHA256(IMG_SIGN_SECRET, "{imageId}|{exp}")). image-serve verifies the signature on each request:
exp < now→410 SIGNATURE_EXPIRED- HMAC mismatch →
403 SIGNATURE_INVALID - Valid signature → request bypasses the
allowedRefererscheck entirely
Expiry policy
- Default expiry: 1 hour
- Maximum: 24 hours (configurable per deployment via
IMG_SIGN_MAX_EXP_SECONDS) - One-time tokens are not supported in v0.32 — within
exp, the URL is reusable.
Secret rotation
Picora supports two coexisting secrets for zero-downtime rotation:
IMG_SIGN_SECRET_PRIMARY— used to sign new URLsIMG_SIGN_SECRET_SECONDARY— verified after primary fails; deployed during the rotation window only
URLs signed under either secret remain valid until exp, so a 90-day rotation cadence does not invalidate any URL with up to 24 h of remaining lifetime. Rotation is performed by ops; users see no churn.
When to use which
| Use case | Recommendation |
|---|---|
| Public blog images, embedded across the open web | allowedReferers: null (default) |
| Images embedded only on your own blog | allowedReferers: ['blog.you.com', '*.you.com'] |
| Paywalled content (visible only to subscribers) | is_public: false + signed URLs (auto-required when private) |
| Time-limited share links | is_public: true + requireSignature: true + short expSeconds |
Errors
| Code | HTTP | When |
|---|---|---|
HOTLINK_DENIED | 403 | Referer didn’t match any allowed pattern |
SIGNATURE_REQUIRED | 403 | Image is private or requireSignature=true but URL has no sig |
SIGNATURE_INVALID | 403 | HMAC mismatch (forgery / tampering / wrong secret) |
SIGNATURE_EXPIRED | 410 | exp < now; clients should request a fresh URL |
INVALID_HOST_PATTERN | 422 | Tried to write a malformed allowedReferers entry |
Like all image-serve errors, the response body is a small placeholder PNG so <img src> tags fail visibly rather than silently.