Rindle is private-by-default. A profile that does not opt into public delivery serves every original and every variant via signed, time-limited URLs. This matches the security posture of the reference implementations Rindle draws from (Active Storage, Shrine, imgproxy) and avoids the most common media-handling mistake: accidentally exposing storage objects via unsigned, infinite-lifetime URLs.

This guide covers:

  • The default private delivery posture
  • How to configure signed URL TTL per profile
  • How to opt a profile into public delivery (and when not to)
  • How to attach an authorizer for fine-grained per-request checks
  • The storage-adapter capability contract for signed URLs
  • Threat-model notes on signed URLs

For the full adapter/provider matrix, proof posture, and future resumable boundary, see Storage Capabilities.

Default: Private with Signed URLs

A profile that declares no delivery: option is private:

defmodule MyApp.MediaProfile do
  use Rindle.Profile,
    storage: Rindle.Storage.S3,
    variants: [thumb: [mode: :fit, width: 64, height: 64]]
end

Calling Rindle.Delivery.url/3 returns a signed URL that expires after the profile's configured TTL (or the application-wide default if the profile does not override it):

{:ok, signed_url} = Rindle.Delivery.url(MyApp.MediaProfile, asset.storage_key)
# => {:ok, "https://my-bucket.s3.amazonaws.com/uploads/abc.png?X-Amz-Signature=..."}

Rindle emits [:rindle, :delivery, :signed] telemetry on every successful URL issuance, with profile, adapter, and mode metadata.

Configuring Signed URL TTL

The default signed URL TTL comes from the application-wide Rindle delivery configuration. A profile can override it per-profile:

defmodule MyApp.SensitiveDocsProfile do
  use Rindle.Profile,
    storage: Rindle.Storage.S3,
    variants: [preview: [mode: :fit, width: 600, height: 600]],
    delivery: [signed_url_ttl_seconds: 300]   # 5-minute URLs
end

For audit-heavy or PHI/PII-bearing media, prefer short TTLs (60–300s) and re-issue on each request. For public-ish content (post images on a logged-in feed), longer TTLs (900s–3600s) are acceptable and reduce signing overhead.

Public Delivery (Explicit Opt-In)

Public delivery is an explicit per-profile opt-in. There is no global toggle; you cannot accidentally make all profiles public.

defmodule MyApp.PublicLogoProfile do
  use Rindle.Profile,
    storage: Rindle.Storage.S3,
    variants: [favicon: [mode: :fit, width: 32, height: 32]],
    delivery: [public: true]
end

When public: true, Rindle.Delivery.url/3 returns the storage adapter's unsigned URL (suitable for direct CDN caching). Use this only for content that is genuinely intended for unauthenticated public consumption — logos, brand assets, marketing imagery — and ideally back the bucket with a CDN that caches and rate-limits at the edge.

Authorizers

For fine-grained per-request authorization (e.g., "only the uploader can view this avatar"), attach an authorizer module:

defmodule MyApp.AvatarAuthorizer do
  @behaviour Rindle.Authorizer

  @impl true
  def authorize(%MyApp.User{} = actor, :deliver, %{key: key} = subject) do
    if owner?(actor, key), do: :ok, else: {:error, :forbidden}
  end

  def authorize(_actor, _action, _subject), do: {:error, :forbidden}

  defp owner?(actor, key), do: String.contains?(key, "users/#{actor.id}/")
end

defmodule MyApp.AvatarProfile do
  use Rindle.Profile,
    storage: Rindle.Storage.S3,
    variants: [thumb: [mode: :fit, width: 64, height: 64]],
    delivery: [authorizer: MyApp.AvatarAuthorizer]
end

Pass the actor in the call options:

{:ok, url} = Rindle.Delivery.url(MyApp.AvatarProfile, key, actor: current_user)

The authorizer runs before the storage adapter signs the URL. A {:error, reason} from the authorizer short-circuits delivery; no URL is issued and no telemetry is emitted. Public delivery still goes through the authorizer if one is configured — opting into public mode does not bypass auth.

Storage Adapter Capabilities

Private delivery requires the storage adapter to support signed URLs. Adapters declare their capabilities via Rindle.Storage.capabilities/0:

Rindle documents the complete capability matrix centrally in Storage Capabilities. For delivery specifically, the contract is:

  • Private delivery needs :signed_url.
  • Public delivery does not need :signed_url.
  • Unsupported private delivery fails explicitly with {:error, {:delivery_unsupported, :signed_url}}.

If you point a private profile at an adapter that does not advertise :signed_url, Rindle.Delivery.url/3 returns {:error, {:delivery_unsupported, :signed_url}} rather than silently falling back to an unsigned URL. This is intentional — the failure mode should be loud, not silent.

Cloudflare R2, when used through the shipped Rindle.Storage.S3 adapter seam, belongs to the same delivery contract. Phase 8 documents it as an adopter-owned compatibility target through the shipped S3 seam, but it does not claim provider-specific live R2 proof in CI and does not add a bespoke R2 adapter.

Variant URLs and the Stale-Variant Fallback

Rindle.Delivery.variant_url/4 resolves a deliverable URL for a specific variant, with safe fallback semantics for non-ready variants:

Variant stateBehavior
readySign and return the variant URL
staleConfigurable: serve stale (:stale_mode :serve_stale) or fall back to original (:fallback_original, default)
missingFall back to the original asset URL
failedFall back to the original asset URL
purgedFall back to the original asset URL

Adopters never see broken-image links because of variant state; the original is always a valid fallback. The stale-variant semantics are controlled by the configured stale-serving policy.

Threat Model Notes

A few important properties to keep in mind when designing around signed URLs:

  • Signed URLs are bearer-token-equivalent until they expire. Anyone who obtains the URL can use it for the lifetime of the signature. Treat signed URLs as secrets in logs and traces — Rindle scrubs them from telemetry metadata, but your own log handlers may not.
  • TTL is a tradeoff. Longer TTLs reduce signing load and improve CDN hit rates, but extend the bearer-token window if the URL leaks. For PHI/PII or financial documents, prefer 60–300s and re-sign on each request.
  • Authorizers run on URL issuance, not on URL use. A signed URL minted for user A still works if user B obtains it (until expiry). For the strongest posture, combine short TTLs with per-request authorization at the application layer (e.g., a Phoenix controller that re-checks permissions and then mints a fresh signed URL).
  • Public delivery cannot be silently re-enabled. Switching from delivery: [public: true] back to private requires intentional code change; there is no environment variable that flips it.
  • Authorizer failure is loud. A {:error, reason} from the authorizer is returned from Rindle.Delivery.url/3; callers cannot silently fall back to an unsigned URL.

Application-Level TTL Default

Set the application-wide default TTL in your runtime config:

# config/runtime.exs
config :rindle, :signed_url_ttl_seconds, 900   # 15 minutes

Per-profile overrides take precedence; profiles that don't set signed_url_ttl_seconds: use this default.

Quick Reference

GoalConfiguration
Private + default TTL(no delivery: block needed)
Private + 5-minute URLsdelivery: [signed_url_ttl_seconds: 300]
Public (CDN-cacheable)delivery: [public: true]
Private + per-request authorizationdelivery: [authorizer: MyAuthorizer]
Public + authorization (rare)delivery: [public: true, authorizer: MyAuthorizer]