Skip to content

IIIF Authorization

iiiris implements the IIIF Authorization Flow API 2.0 and advertises the older IIIF Authentication API 1.0 service tree side-by-side for migration compatibility — matching the project's existing dual-version stance on the Image API itself. The subsystem lives under internal/auth/ and is wired into the IIIF image and info.json handlers via an Authorizer seam. When no auth: block is configured, the zero-config AllowAll authorizer is installed and the request path is unchanged — nothing is gated and no auth services are advertised in info.json.

This doc is the operator reference. Code orientation lives in architecture.md; the YAML schema lives in configuration.md; copy-pasteable sample configs for every pattern × backend combination live in examples/. The durable implementation contract — what the subsystem guarantees, how its components relate — lives in specs/auth.md.

Start here

This is the most detailed doc in the set; jump to what you need:

The four services

Per the spec, an Auth 2.0 deployment exposes four cooperating services. iiiris mounts all four under /iiif/auth/:

Service Route Type Purpose
Probe GET /iiif/auth/probe/{identifier...} AuthProbeService2 Tells a viewer whether the current request has access (200/401/302), and what to do if not.
Access GET, POST /iiif/auth/login/{profile} AuthAccessService2 The login flow — built-in UI, redirect to SSO, or kiosk check.
Access Token GET /iiif/auth/token AuthAccessTokenService2 Issues a short-lived Bearer token to the viewer via postMessage (cross-origin friendly).
Logout GET, POST /iiif/auth/logout AuthLogoutService2 Tears down the session and any active tokens.

All four routes are public — they are not under /admin. CORS is applied per route; see CORS + cookies below.

IIIF Auth 1.0 routes

For migration compatibility, iiiris also mounts the smaller IIIF Auth 1.0 surface under /iiif/auth/v1/. Auth 1.0 has no probe service; the access service is itself the entry point.

Service Route 1.0 profile URI Notes
Login (access) GET, POST /iiif/auth/v1/login/{profile} http://iiif.io/api/auth/1/{clickthrough,login,kiosk,external} Shares the v2 login handler — the session cookie it mints is identical regardless of which version's advertisement led the viewer here.
Token GET /iiif/auth/v1/token http://iiif.io/api/auth/1/token postMessage iframe flow, same input contract as v2. Response payload differs: no @context / type, success keys are {accessToken, expiresIn, messageId}, failure keys are {error, description, messageId}.
Logout GET, POST /iiif/auth/v1/logout http://iiif.io/api/auth/1/logout Shares the v2 logout handler.

The 1.0 spec uses Auth-1-specific naming for the active pattern — what iiiris config (and Auth 2.0) calls active is advertised with the v1 profile URI http://iiif.io/api/auth/1/login. The other three pattern names are identical between versions.

The four patterns

Each profile names one of the four IIIF Auth 2.0 interaction patterns:

Pattern Viewer experience iiiris behavior
clickthrough Single "I agree" button. Built-in HTML page; POST mints session with no credentials captured.
active Username + password form. Built-in HTML page; POST verifies against an inline users: map or an htpasswd_file:.
kiosk No UI. Login URL is loaded by the viewer in an iframe; if the originating IP is on the profile allowlist a session is minted automatically, otherwise 401.
external Viewer is redirected away from iiiris. iiiris 303s to the operator's auth service (or OIDC issuer) and accepts a signed callback.

The pattern name is advertised verbatim as the profile field of the AuthAccessService2 block in info.json, so existing IIIF Auth 2.0 viewers know how to render it.

The four access-service backends

The access_service.backend field on each profile selects how the user's identity actually arrives. Backends compose with patterns: clickthrough + builtin is the canonical click-through deployment; external + oidc is a full OIDC login; active + header is rejected by Build (the patterns are mutually exclusive — interactive vs. ambient — so the combination is treated as a config mistake).

Backend Credentials live where Required config
builtin Inline users: map, or htpasswd_file: on disk. One of users: or htpasswd_file: for the active pattern. Empty for clickthrough / kiosk.
header A reverse-proxy header (e.g. oauth2-proxy, Pomerium, Tailscale). header: name (defaults to X-Forwarded-User).
external A separate operator-run auth service that redirects back with an HMAC-signed query string. url: of the auth page; callback_secret_env: naming an env var that holds the shared HMAC-SHA256 secret.
oidc An OpenID Connect issuer. issuer:, client_id:, client_secret_env:; optional scopes: (defaults to [openid, profile, email]) and user_claim: (defaults to sub).

builtin

iiiris hosts the login UI. For clickthrough the page is a single button ("Continue", or the profile's confirm_label). For active it is a username/password form. The built-in template is minimal HTML with system fonts; supply a path to your own template via access_service.template to override it.

The override is parsed at startup using Go's html/template. A missing or unparseable file is a hard build error so misconfiguration surfaces immediately rather than on the first denied request. The template receives a struct with the following fields:

Field Type Source
.Title string First value of profile.label (or "Access").
.LogoURL string profile.logo_url.
.Heading string First value of profile.heading, or falls back to .Title.
.Description string First value of profile.description (may be empty).
.ConfirmLabel string profile.confirm_label, or "Sign in" (active pattern) / "Continue" (other).
.ReturnTo string Echoed from the request (set as a hidden form input).
.Active bool True when the profile pattern is active — render the username/password inputs in that case.
.Username string On a re-render after a failed POST, the previously submitted username.
.Error string On a re-render, the error message to display (empty on the first GET).

The override is restart-required; iiiris does not watch the file for changes (unlike htpasswd_file:, which is mtime-watched). The built-in template in internal/auth/service/login.go is a useful starting point if you want to clone-and-tweak.

iiiris does no UI of its own — the reverse proxy in front of iiiris has already authenticated the user and is forwarding a header. The Authorizer reads that header on the image and info.json paths; the configured value is treated as the user identity and the request is allowed. The access-service block is omitted from info.json for header-backend profiles (there is no interactive flow for the viewer to initiate), but the probe service is still advertised so a viewer can discover access status.

Important: the reverse proxy must strip the configured header from untrusted clients before forwarding. iiiris trusts the header unconditionally when present.

external

GET /iiif/auth/login/{profile} redirects (303) to the operator's auth URL with two query parameters:

  • callback — the absolute URL the operator must redirect back to.
  • return_to — passed through verbatim from the viewer.

The operator's auth service authenticates the user and redirects to:

{callback}?user={user}&expires={unix-seconds}&signature={hex-or-base64url}

signature is HMAC-SHA256(secret, user + "|" + expires) — hex or base64url encoding both accepted. iiiris constant-time-compares the signature, rejects callbacks past the expires timestamp, and mints a session for user.

oidc

GET /iiif/auth/login/{profile} redirects to the issuer's authorization endpoint (discovered via {issuer}/.well-known/openid-configuration). The callback exchanges the code for tokens, validates the ID token, and extracts the user identity from the configured user_claim (default sub).

Rules

The rules: list maps identifier patterns to profile names. Rules are evaluated in order; first match wins. An identifier matching no rule is treated as public — the request is allowed regardless of session state and no auth service block is added to info.json.

The match shape is intentionally small:

Form Example Meaning
Exact restricted Identifier equals restricted.
Trailing prefix private/* Identifier is private or begins with private/.
Catch-all * Every identifier matches.

Anything richer (regex, content-type checks, time-of-day) belongs in the Hook.Authorize engine — see Hook integration.

Substitute behavior on deny

When a request is denied, the profile's substitute.max_size (an IIIF size string like !400,400 or ^max) controls what happens next:

  • Empty substitute.max_size — hard deny. The handler writes the HTTP status from the Decision (defaults to 401) and the configured Heading + Note are surfaced via the probe service.
  • Non-empty substitute.max_size — the IIIF request's size parameter is rewritten to the configured value and the pipeline runs normally against the source. The viewer receives a degraded version of the same image at the request's URL — no redirect. The result is cached under the substitute cache tier (see Cache-tier keying).

Hook.Authorize can additionally inject a per-request alternate identifier via AuthDecision.Substitute. That identifier is substituted into the request and the pipeline runs against that source instead — useful for swapping a high-res master for a watermarked proxy. Hook-supplied identifier substitution takes precedence over the profile's max_size.

Cache-tier keying

The render and info caches are keyed with an auth tier so a denied request never serves bytes that were cached for an allowed user (or vice versa). Three tiers, capped vocabulary:

Tier When Key shape (RenderCache)
public Identifier has no matching rule. {r.URL.Path} — unchanged from pre-auth iiiris.
full Identifier is gated, request is authorized. {r.URL.Path}\|v1\|full
substitute Identifier is gated, request denied, substitute served. {r.URL.Path}\|v1\|substitute

InfoCache follows the same pattern with r.Host + r.URL.Path as the base. The v1 segment is cacheKeyVersion — bumping it (when the tier vocabulary changes) cleanly invalidates the cache rather than silently colliding with old-shape keys.

The public-tier key is byte-identical to the pre-auth key shape, so introducing auth into an existing deployment doesn't churn unrelated cache entries.

Admin dashboard

The /admin/auth page surfaces the subsystem read-only: configured profiles + rules, active session count (with per-profile breakdown when the session backend supports it), and a bounded ring buffer of the most-recent 256 allow/deny decisions. The 24h allow/deny tally and the recent-decisions table are drawn from auth.DecisionLogRuleAuthorizer emits one event per Authorize call when the recorder is wired (which cmd/iiirisd/main.go does whenever an auth: block is configured). See admin.md.

Token and session lifetimes

Knob Default Notes
token_ttl 5m Bearer-token lifetime. Set per-profile or globally. The viewer requests a fresh token via /iiif/auth/token whenever its existing one nears expiry.
session_ttl 24h Server-side session lifetime. The session cookie's Expires matches.

Tokens are opaque random strings, not JWTs — the spec doesn't require JWTs and opaque tokens avoid key-rotation surface area. Sessions and tokens are stored separately so one session can vend many tokens.

Session storage

The session store is selected via auth.session.backend. Pick by your deployment shape:

You have… Use Trade-off
One replica; losing sessions on restart is fine heap Zero-config, no dependencies. Sessions vanish on every deploy.
One replica; sessions must survive restarts, no external deps filesystem Simplest durable store. Single-host only.
One replica in an ephemeral/container host (e.g. K8s) s3 Survives pod restarts. Cleanup is by bucket lifecycle policy, not automatic.
Multiple replicas redis The only multi-replica-safe backend (atomic session↔token index). Adds a Redis dependency.

Details for each follow.

heap (default)

An in-memory map of sessions and tokens. Zero-config; sessions vanish on restart. The right choice for a single iiirisd process where session loss across deploys is acceptable.

auth:
  session:
    backend: heap
    ttl: 10m         # sweep interval for expired sessions

filesystem

Sessions and tokens persist as JSON files under a configured root, so they survive an iiirisd restart. Files are sha-sharded (<root>/sessions/<aa>/<bb>/<full-hash>.json, <root>/tokens/<aa>/<bb>/<full-hash>.json) so a directory listing never has to enumerate the full set. Writes are atomic (temp + rename) so a concurrent reader never observes a partial record. Expired entries are deleted lazily on Get and proactively on Sweep.

auth:
  session:
    backend: filesystem
    path: /var/lib/iiiris/sessions
    ttl: 10m

path: is required; missing or unwritable is a build error so misconfiguration fails at startup.

Running multiple iiirisd replicas against the same filesystem root is unsupported — each process owns its own in-memory mutex, and concurrent writes to the same file from different processes are not coordinated. Use a single-replica deployment, or wait for a future shared backend (Redis / DB) if you need multi-replica.

s3

Sessions and tokens persist as JSON objects under a configured S3 prefix. Suitable for single-instance iiirisd deployments that want persistent sessions without managing local disk (containers in ephemeral filesystems, autoscaling groups whose instance host can change). Strongly read-after-write consistent (S3's posture for new keys), so callers can't observe a torn write.

auth:
  session:
    backend: s3
    s3:
      bucket: iiiris-sessions
      region: us-east-1
      prefix: prod/sessions
      endpoint: ""        # for MinIO and other S3-compatibles

Object layout: <prefix>/sessions/<sha256>.json and <prefix>/tokens/<sha256>.json. Object content is the JSON-encoded record. AWS credentials follow the standard SDK chain (env vars, shared config, IMDS).

Long-term cleanup is the operator's job — set an S3 lifecycle policy on the configured prefix (Expiration { Days: N } where N exceeds your longest session_ttl). iiiris's Sweep is a no-op for the S3 backend; expired records are still dropped on read by GetSession / GetToken, but accumulated tombstoned objects need lifecycle to evict.

Cascade on DeleteSession (logout) lists the tokens prefix and deletes any token whose SessionID matches the deleted session — one ListObjectsV2 + one GetObject per token + one DeleteObject per match. This is bounded by your active-token cardinality (typically dozens) but it is not free; logout latency is proportional to the token-prefix size. If the token tree grows to thousands of active entries, consider a session-indexed sub-prefix or a separate by-session listing object — the current shape was chosen for symmetry with the filesystem backend.

redis

Sessions and tokens persist as Redis strings (JSON-encoded) under a configurable prefix. The only backend that supports multi-replica iiirisd deployments today — Redis's atomic single-key semantics guarantee concurrent CreateToken from one replica can't race with DeleteSession's cascade from another.

auth:
  session:
    backend: redis
    redis:
      addr: redis.internal:6379
      password_env: REDIS_PASSWORD   # env-var-resolved
      db: 0
      tls: true
      prefix: iiiris                 # namespace for shared-Redis deployments

Key layout (with the optional prefix):

  • {prefix}:sessions:<sha256(id)> — string, JSON Session record
  • {prefix}:tokens:<sha256(value)> — string, JSON Token record
  • {prefix}:session-tokens:<sha256(id)> — set; members are the sha-keys of bound tokens

Native Redis TTL handles expiry — records evict on their own clock, no iiiris-side sweep needed. A secondary ExpiresAt guard on read catches the rare clock-drift case where the JSON-encoded expiry is past but Redis hasn't yet evicted.

DeleteSession (logout) cascades atomically: one SMEMBERS of the session-tokens set, then a single pipelined DEL removing the session record, the index, and every bound token. One round trip vs. the linear scan needed by the filesystem and S3 backends.

CountActive returns the total only (via SCAN). A per-profile breakdown would require reading every record (same constraint as the S3 backend) and is intentionally not implemented; deployments that need it can maintain a side-channel counter via HINCRBY on a profile-counts hash in their own integration.

The connection is verified with a PING at startup so misconfiguration fails fast. Connect failures, auth failures, and TLS handshake errors all surface at boot rather than at the first session write.

Multi-instance posture

Use the redis backend for any deployment with more than one iiirisd replica. The other backends (heap, filesystem, s3) are all single-instance only — concurrent processes writing the same backing store from multiple iiirisd replicas race in ways that can leak tokens past their session's logout. A single-replica deployment can use any backend.

htpasswd

The active pattern verifies credentials against an htpasswd-format file or an inline map. Supported hash schemes:

  • bcrypt$2a$, $2b$, $2y$ prefixes. Generated by htpasswd -B. Recommended.
  • Apache APR1-MD5$apr1$<salt>$<hash>. Generated by htpasswd -m. Legacy but ubiquitous.

Plaintext, crypt(3), and SHA-1 entries are explicitly rejected.

When both users: and htpasswd_file: are configured on the same profile, htpasswd_file: wins (the file is treated as the authoritative source). The file is mtime-watched: editing it on disk reloads the user list at the next verification call — no restart required.

Hook integration

When to reach for this: use Hook.Authorize when an access decision needs per-request logic the static Rules globs can't express. A lua hook sees the identifier and request_path (so it can do regex / computed matching richer than the rule prefixes); a webhook hook delegates to your own service, which can factor in anything it knows (time, client, user claims). Either way it runs after the rule engine and can only tighten a rule-driven allow (refuse it), never lift a deny.

Example — embargo a sub-collection the prefix rules can't target. Rules allow archive/*, but anything under an embargoed/ segment must be refused:

function authorize(identifier, request_path)
  if identifier:match("/embargoed/") then
    return { allow = false, reason = "embargoed" }
  end
  return nil   -- no opinion; the rule-engine verdict stands
end

internal/hook.Hook.Authorize runs after the rule-engine verdict. The hook can:

  • Refuse access (Allow: false) even when the rules allowed it.
  • Supply a per-request Substitute identifier (overrides profile.Substitute.MaxSize).
  • Supply a Redirect URL (the handler issues a 303).
  • Override Profile (informational; used for logging).

The hook cannot lift a rule-driven deny to an allow. This is deliberate: the common no-opinion hook shape — Noop, an empty Lua script, a webhook returning {} — defaults to Allow: true per hooks.md, and we don't want a default-allow hook to accidentally lift an auth gate set in YAML.

CORS + cookies

The session cookie is set with Secure, HttpOnly, and SameSite=None (the last is required so a viewer on a different origin can attach the cookie to its iframe-driven probe + token requests). Path is auto-derived from X-Forwarded-Prefix when iiiris is mounted under a sub-path behind a proxy; set auth.cookie.path explicitly to override.

CORS is split by service:

  • Probe defaults to wildcard (Access-Control-Allow-Origin: *) because the probe payload is informational. Narrow it via auth.cors.probe_origins.
  • Token is intentionally strict — the postMessage payload contains a credential. auth.cors.token_origins must list the viewer origins that may request tokens. Empty list means no origin is permitted.

info.json advertisement

The auth service trees are inserted into the service array of every gated identifier's info.json (both v2 and v3). Both Auth 2.0 and Auth 1.0 trees are emitted side-by-side, in that order. Modern viewers follow the 2.0 tree they prefer; older viewers that only speak 1.0 follow the 1.0 entry.

service[0]: AuthProbeService2          (IIIF Auth 2.0)
            └── AuthAccessService2     (omitted for header backends)
                ├── AuthAccessTokenService2
                └── AuthLogoutService2

service[1]: { @context, @id, profile:  (IIIF Auth 1.0)
              "http://iiif.io/api/auth/1/{pattern}",
              label, header, description,
              confirmLabel,
              service: [ token, logout ] }

Pattern name, label, heading, description, and confirmLabel are shared across both trees (the v2 entry uses the IIIF JSON-LD language map shape; the v1 entry uses scalar strings — iiiris collapses the language map by preferring English, then falling back to any value).

Header-backend profiles advertise the v2 probe service only — no v2 access service (there is no interactive flow for the viewer to initiate) and no v1 entry at all (1.0 has no probe-only mode, so a v1 advertisement without an interactive access service would be ambiguous).

IP allowlist (kiosk) and trusted proxies

The kiosk pattern matches the client's IP against profile.kiosk.allowed_ips (single IPs or CIDRs, v4 or v6). The client IP is extracted via RealIP, which:

  1. Reads RemoteAddr from the TCP connection.
  2. If that address is in auth.trusted_proxies, walks X-Forwarded-For right-to-left and returns the rightmost hop that is not itself a trusted proxy — i.e. the real client.
  3. Otherwise returns RemoteAddr directly.

This prevents a client that doesn't go through a trusted proxy from forging X-Forwarded-For. Configure auth.trusted_proxies whenever iiiris sits behind anything (a load balancer, a CDN, a Kubernetes ingress) — without it, kiosk allowlists match the proxy's IP, not the client's.

Validator posture

No published upstream validator covers IIIF Auth 2.0 today. The spec site lists none; the historical github.com/IIIF/auth-validator repo is 404. In place of an upstream tool, iiiris ships a project-local conformance harness — tools/iiif-auth-conformance — that drives the spec-defined endpoints and asserts the response shapes (@context, type, status, payload fields per §3–§7 of the spec). The harness is wired into CI as the iiif-auth-validate stage in .gitlab-ci.yml and is load-bearing: a regression in any of the four interaction patterns fails the pipeline.

The CI job builds iiirisd, starts it with the sample multi-profile config in tools/iiif-auth-conformance/config.yaml, and runs the harness once per interaction pattern — clickthrough, active, kiosk, header — capturing the combined report as the iiif-auth-validation.log artifact.

Each interactive pattern (clickthrough, active, kiosk) runs the same nine-check sequence (info.json shape, unauth probe, public probe, login → cookie, authed probe, token postMessage, Bearer-grants-probe, token error without cookie, logout invalidates session). The login mechanism varies per pattern: clickthrough = empty POST, active = POST username+password, kiosk = GET (the client IP must be in the profile's allowed_ips).

The header backend has no interactive flow and emits a probe-only advertisement, so it runs a shorter four-check sequence (advertisement shape, unauth probe, public probe, header-authorized probe).

external (HMAC-signed callback) and oidc backends are out of scope for the harness — they require an operator-hosted upstream to complete the flow. iiiris's side of those contracts is covered by the unit + integration tests (internal/auth/service/external_test.go, the OIDC client tests). When community mocks of the operator side become available, those flows can be folded back into the harness.

See tools/iiif-auth-conformance/README.md for the full check list, the spec section references, and how to run the harness locally.

Posture decision. Strict (no allow_failure). The harness is project-local rather than community-blessed, so "passing" ultimately means "passing against iiiris's own definition of conformance" — but in practice the harness encodes the spec's response shapes verbatim, the four patterns it covers are stable, and we'd rather catch a regression at merge time than chase it later. When a community-blessed Auth 2.0 validator emerges, swap the binary out of the job; the posture stays the same.

Reference-viewer matrix

Smoke-tested against the most-deployed viewers. "✓" means the pattern works end-to-end with iiiris's stock advertisement; missing entries mean we haven't verified, not that they fail.

Viewer clickthrough active kiosk external (OIDC)
Mirador 3.x
Universal Viewer 4.x
Clover

When you verify a new combination, please extend this table in the same PR — the matrix is the operator's quickest signal for what's expected to work.

Migration from Auth 1.0

iiiris emits both v1 and v2 service trees, so a viewer that speaks either version finds a working path. Same cookie, same session store, same login handler under the hood — the access service at /iiif/auth/login/{profile} is identical whether the viewer found it via the 2.0 advertisement or via the 1.0 advertisement at /iiif/auth/v1/login/{profile}. The split lives only in the advertised service tree and in the token service's response shape.

For migrating an existing 1.0 deployment:

  1. Pick the closest Auth 2.0 pattern for each existing 1.0 flow — clickthrough → clickthrough, login → active, kiosk → kiosk, external → external. iiiris advertises both versions automatically; you only configure once.
  2. Update operator-side login pages if any; the redirect and callback shapes for external differ between 1.0 and 2.0 deployments. The iiiris-side handlers (/iiif/auth/login/... and /iiif/auth/v1/login/...) are the same — the difference is on the operator's auth service.
  3. Re-test viewers. Modern viewers (recent Mirador, Universal Viewer 4.x, Clover) prefer the 2.0 tree; older 1.0-only viewers follow the 1.0 tree.
  4. The session cookie name is iiiris_session by default — pick a different name via auth.cookie.name if your existing reverse-proxy stack already uses it.

Things deferred

  • Distributed session storage (filesystem / S3 / Redis). Heap-only today; multi-replica deployments are out of scope.
  • Per-prefix profile routing as a built-in. The Lua engine already supports this via Hook.Authorize; the built-in rules: list is intentionally small.
  • Built-in login template hot-reload. Override is parsed once at startup; restart after editing.
  • JWT tokens. Tokens are opaque random strings. No token_strategy: knob is reserved.

Tests

File Covers
internal/auth/rules_test.go Rule matching (exact / prefix / catch-all).
internal/auth/authorizer_test.go RuleAuthorizer.Authorize verdict matrix; hook interaction.
internal/auth/build_test.go YAML → built profile/rule/session construction; backend validation.
internal/auth/advertisement_test.go Service-tree shape (both v2 and v1, including header-backend omission and pattern → v1-profile-URI mapping).
internal/auth/cachekey_test.go Tier-keyed cache key shapes; public-tier byte-identity.
internal/auth/realip_test.go X-Forwarded-For parsing under trusted/untrusted proxies.
internal/auth/htpasswd/* bcrypt and APR1 verification; file mtime watch; rejection of plaintext/SHA-1/crypt.
internal/auth/service/* Probe envelope; login flow per backend; v2 token postMessage; v1 token postMessage (v1-shape payload + error keys); logout; external HMAC verification; kiosk IP match; mux registration covering all v2 + v1 routes.
internal/auth/session/heap_test.go LRU eviction; expiry sweep; concurrent access.
internal/server/auth_backends_test.go End-to-end backend flows behind the real server.
internal/server/auth_integration_test.go Full request lifecycle (image + info.json + probe + token) per pattern.