Skip to content

Hotlink Protection

Picora lets you control who can hot-link your images via two complementary mechanisms:

  1. Referer whitelist — per-image list of host patterns; non-matching Referer headers are blocked at image-serve before any bytes leave the CDN edge.
  2. 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

PatternMatches
selfAll Picora-owned domains (picora.me / picora.cn / web.moraya.app, plus subdomains)
blog.example.comExact host (case-insensitive). Does not match sub.blog.example.com
*.example.comAny subdomain (including multi-level). Does not match example.com itself
example.comRoot domain only; does not match subdomains

Mid-string wildcards like sub.*.com are rejected at write time.

Three states

allowedReferers valueMeaning
nullNo 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/sign
Content-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 < now410 SIGNATURE_EXPIRED
  • HMAC mismatch → 403 SIGNATURE_INVALID
  • Valid signature → request bypasses the allowedReferers check 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 URLs
  • IMG_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 caseRecommendation
Public blog images, embedded across the open weballowedReferers: null (default)
Images embedded only on your own blogallowedReferers: ['blog.you.com', '*.you.com']
Paywalled content (visible only to subscribers)is_public: false + signed URLs (auto-required when private)
Time-limited share linksis_public: true + requireSignature: true + short expSeconds

Errors

CodeHTTPWhen
HOTLINK_DENIED403Referer didn’t match any allowed pattern
SIGNATURE_REQUIRED403Image is private or requireSignature=true but URL has no sig
SIGNATURE_INVALID403HMAC mismatch (forgery / tampering / wrong secret)
SIGNATURE_EXPIRED410exp < now; clients should request a fresh URL
INVALID_HOST_PATTERN422Tried 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.