Skip to content

Secure Image Links

Lychee can protect the URLs it generates for photo files with two independent, combinable mechanisms:

  • Temporary (signed) links — every image URL gets a time-limited, tamper-evident signature. Free, available to everyone.
  • AES-secured links — the file’s storage path itself is encrypted, so URLs aren’t guessable or enumerable. Requires a Supporter Edition licence.

Both live under Settings → Privacy and can be turned on separately or together.

When temporary_image_link_enabled is on, image URLs are wrapped in a Laravel signed URL: an expires timestamp and a signature query parameter (an HMAC over the full URL, keyed by APP_KEY) are appended. A request is rejected if the timestamp has passed, or if the signature doesn’t match — which happens the moment anyone edits the path or query string, since that invalidates the HMAC.

SettingDescriptionDefault
temporary_image_link_enabledServe all images through signed, expiring URLs.off
temporary_image_link_when_logged_inAlso require signing for logged-in users (not just guests).off
temporary_image_link_when_adminAlso require signing for admins.off
temporary_image_link_life_in_secondsHow long a signed link stays valid.86400 (24h)

By default, logged-in users and admins are exempt from signing even when it’s enabled for guests — _when_logged_in and _when_admin opt them back in. This lets you protect public/shared links without adding signature overhead to your own browsing session.

When secure_image_link_enabled is on, the image’s storage path (e.g. c3/3d/c661c594a5a781cd44db06828783.png) is encrypted before it’s placed in the URL, using the same APP_KEY/APP_CIPHER (AES-256-CBC by default) as the rest of Lychee — see Configuration. Laravel’s encryption is authenticated (encrypt-then-MAC), so a tampered or guessed ciphertext fails to decrypt rather than resolving to some other file. This makes it infeasible to enumerate or guess valid image URLs, even without temporary links enabled.

SettingDescriptionDefault
secure_image_link_enabledEncrypt the storage path embedded in image URLs.off

Both protections are enforced by a single endpoint, GET /image/{path}, which is only reachable at all if at least one of the two settings above is enabled — otherwise images are served directly from the storage disk’s plain URL and this endpoint isn’t used.

For each request:

  1. If temporary links apply, the expires timestamp and signature are checked first; an expired or invalid signature is rejected immediately.
  2. If AES links are enabled, the path is decrypted; a payload that fails to decrypt (wrong key, corrupted, or just guessed) is rejected.
  3. The decrypted/plain path is checked for path-traversal sequences (.., %2e, %2f, \) and, after resolving it, verified to still be located inside the upload storage root — rejected otherwise.
  4. Only after all of the above does Lychee check whether the file actually exists, so a traversal attempt and a merely-missing file can’t be told apart by an attacker probing the endpoint.
  5. The file is streamed back.

Path-traversal attempts are rejected with an unusual 418 I'm a teapot response rather than a generic error — this is intentional, and lets Lychee’s honeypot tooling recognize and rate-limit/ban repeat offenders the same way it does for other attack probes.

If your images disk is backed by S3 (see AWS configuration), none of the above applies — Lychee instead returns the bucket’s public URL directly, or asks S3 for its own native pre-signed temporary URL (valid for temporary_image_link_life_in_seconds) if the bucket is private. S3’s own access controls take over at that point.