Operational Context
A rights-metadata engineer wires the institution’s publication policy into the IIIF layer, and the failure surfaces at validation time: a manifest carries a rights string the collections database happened to store — "© Trustees, all rights reserved", or a bespoke https://collections.example.org/license/17 URI — and a conformance checker rejects it, or worse, a downstream aggregator silently drops the record because the value is not a recognized rights statement. The engineer’s task is narrow and exacting: populate the IIIF Presentation 3.0 rights property with a single URI drawn only from the Creative Commons or RightsStatements.org vocabularies, and populate requiredStatement with a label/value attribution the viewer must display. Neither property controls who can see the pixels — that decision was already made in the rights metadata and licensing automation stage and is enforced at the image server. This page sits inside building IIIF Presentation manifests and covers exactly how to resolve a source rights value into a compliant rights URI and a correct requiredStatement language map, and when to omit rights entirely rather than emit something invalid.
Root Cause Analysis
Three distinct mistakes produce most non-conformant rights blocks, and each has a single underlying cause.
First, free text where a URI belongs. The Presentation 3.0 specification is unambiguous: if present, rights must be a string, that string must be a URI, and the URI must come from the Creative Commons license set or the RightsStatements.org statement set. A human-readable credit line, a copyright notice, or a rights label is not a URI and is not drawn from those vocabularies, so it is invalid. The cause is treating one field as two: the machine-actionable license identifier and the human-readable attribution are separate properties. The identifier goes in rights; the prose goes in requiredStatement.
Second, arbitrary or institution-local URIs. A URI that resolves — even one that returns a perfectly good rights page — still fails if its host is not creativecommons.org or rightsstatements.org. IIIF constrains the vocabulary, not merely the syntax, precisely so that a client anywhere in the world can map the value to a known license without dereferencing it. A local URI defeats that guarantee, which is why the correct move for an unmappable source value is to omit rights rather than invent one. Choosing the right target URI is the job of mapping Creative Commons licenses to IIIF rights and mapping RightsStatements.org to collection fields; this page assumes that mapping exists and validates its output.
Third, treating requiredStatement as access control. requiredStatement is a display obligation: a conforming viewer must show the label and value before or alongside the content. It is not a gate, a paywall, or an entitlement check. Engineers sometimes push an embargo or a “members only” condition into it and assume the viewer will withhold the image — it will not. Access is enforced upstream, by returning a restricted manifest or by refusing the Image API request, never by a string in the manifest body. Conflating display metadata with enforcement is how an “embargoed” asset ends up fully readable behind a polite attribution notice.
Canonical Solution
The routine below resolves a source rights value in one pass: it canonicalizes the candidate URI, matches it against the allowed Creative Commons and RightsStatements.org patterns, and either attaches a validated rights string or omits the property. Separately it builds the requiredStatement as a pair of IIIF language maps. A strict Pydantic v2 model makes an unmappable URI a hard failure at the boundary rather than a silent leak into the manifest.
from __future__ import annotations
import re
from urllib.parse import urlsplit, urlunsplit
from pydantic import BaseModel, ConfigDict, field_validator
# --- Allowed rights vocabularies -------------------------------------------
# IIIF Presentation 3.0 constrains `rights` to ONE URI drawn only from Creative
# Commons or RightsStatements.org. These patterns encode the canonical path
# shapes of each vocabulary; anything else is rejected, never passed through.
_CC_LICENSE = re.compile(
r"^https?://creativecommons\.org/licenses/"
r"(by|by-sa|by-nc|by-nd|by-nc-sa|by-nc-nd)/\d\.\d/$"
)
_CC_PUBLIC = re.compile(
r"^https?://creativecommons\.org/publicdomain/(zero|mark)/1\.0/$"
)
_RS_VOCAB = re.compile(
r"^https?://rightsstatements\.org/vocab/"
r"(InC|InC-OW-EU|InC-EDU|InC-NC|InC-RUU|"
r"NoC-CR|NoC-NC|NoC-OKLR|NoC-US|CNE|UND|NKC)/1\.0/$"
)
def canonical_rights_uri(uri: str) -> str:
# Normalize scheme/host casing and force a single trailing slash so that
# equivalent inputs collapse to one comparable string before matching.
parts = urlsplit(uri.strip())
host = parts.netloc.lower()
path = parts.path if parts.path.endswith("/") else parts.path + "/"
return urlunsplit((parts.scheme.lower(), host, path, "", ""))
def is_allowed_rights(uri: str) -> bool:
c = canonical_rights_uri(uri)
return bool(_CC_LICENSE.match(c) or _CC_PUBLIC.match(c) or _RS_VOCAB.match(c))
# --- requiredStatement (display attribution, NOT access control) ------------
# Both `label` and `value` are IIIF language maps: a BCP 47 language tag mapped
# to a list of plain strings, where each string renders as its own line.
LanguageMap = dict[str, list[str]]
class RequiredStatement(BaseModel):
model_config = ConfigDict(extra="forbid")
label: LanguageMap
value: LanguageMap
@field_validator("label", "value")
@classmethod
def non_empty_language_map(cls, v: LanguageMap) -> LanguageMap:
if not v:
raise ValueError("language map needs at least one language key")
for tag, lines in v.items():
if not tag or not lines:
raise ValueError(f"empty tag or line list for {tag!r}")
return v
def build_required_statement(
attributions: list[str],
lang: str = "en",
label: str = "Attribution",
) -> RequiredStatement:
# A resource may carry only ONE requiredStatement, so multiple credit lines
# become multiple entries in the value array under a single shared label;
# a conforming viewer renders them as separate lines, not separate blocks.
return RequiredStatement(label={lang: [label]}, value={lang: list(attributions)})
class ManifestRights(BaseModel):
model_config = ConfigDict(extra="forbid")
rights: str | None = None
required_statement: RequiredStatement | None = None
@field_validator("rights")
@classmethod
def rights_from_allowed_vocab(cls, v: str | None) -> str | None:
# Missing rights is legal — omit rather than fabricate. A present value
# MUST canonicalize into the CC or RightsStatements.org vocabulary.
if v is None:
return None
if not is_allowed_rights(v):
raise ValueError(
f"rights URI {v!r} is not a Creative Commons or "
"RightsStatements.org URI; omit the property instead"
)
return canonical_rights_uri(v)
def apply(self, manifest: dict) -> dict:
# Attach `rights` only when it validated; requiredStatement is display
# metadata and may stand alone even when no license URI is available.
if self.rights is not None:
manifest["rights"] = self.rights
if self.required_statement is not None:
manifest["requiredStatement"] = self.required_statement.model_dump()
return manifestGiven a resolved license URI and one or more credit lines, wiring the block into a manifest skeleton is a single call:
block = ManifestRights(
rights="http://creativecommons.org/licenses/by-sa/4.0/",
required_statement=build_required_statement([
"Provided by the Example Museum of Art.",
"Photograph © 2026 J. Rivera, licensed CC BY-SA 4.0.",
]),
)
manifest = block.apply({
"@context": "http://iiif.io/api/presentation/3/context.json",
"id": "https://example.org/iiif/obj-42/manifest",
"type": "Manifest",
})The resulting manifest fragment is exactly what a viewer consumes: a single machine-readable rights URI and a requiredStatement whose language maps carry the attribution the viewer is obligated to display.
{
"rights": "http://creativecommons.org/licenses/by-sa/4.0/",
"requiredStatement": {
"label": { "en": ["Attribution"] },
"value": {
"en": [
"Provided by the Example Museum of Art.",
"Photograph © 2026 J. Rivera, licensed CC BY-SA 4.0."
]
}
}
}The decision the validator encodes — resolve a source value, test it against the allowed vocabulary, then either set both properties or omit rights — is the branch that trips up most engineers, so it is worth seeing laid out.
Edge Cases and Variants
- In-copyright vs. Creative Commons vs. public domain. A CC license URI belongs only on a work the institution has actually licensed under those terms. An in-copyright work with no open license takes a RightsStatements.org statement —
InC,InC-EDU, or the orphan-orientedInC-RUU— never a CC URI. A true public-domain asset takes the Creative Commons Public Domain Mark (publicdomain/mark/1.0/) or, for a rights waiver, CC0 (publicdomain/zero/1.0/); use the RightsStatements.orgNoC-*“No Copyright” statements when the institution’s position is that no copyright applies. The validator accepts all of these because it matches the vocabulary, not the semantics — getting the right URI for the work is the mapping upstream. - Multiple attributions. Because a resource carries only one
requiredStatement, combine credit lines into thevaluelanguage map’s array rather than repeating the property. A viewer renders each array element on its own line. If a photographer, a lender, and the holding institution all need crediting, that is three strings under oneAttributionlabel, not three statements. - Multilingual attribution. Add parallel language keys —
{"en": [...], "fr": [...]}— and the viewer picks the one matching the user’s locale, falling back per the IIIF language-map rules. Use the"none"key only for a string with no meaningful language, such as a bare institution name or a symbol. - Embargoed items stay dark. When the asset is under embargo, the correct output is no public manifest at all (or a restricted one), decided by setting date-based embargo triggers. Do not publish a readable manifest and rely on
requiredStatementto hold viewers back — it cannot. - Strict vs. lenient ingestion. In a batch backfill, wrap
ManifestRights(...)intry/except ValidationErrorand route the offending record to review withrightsunset, so one bad legacy value never blocks the run; in an interactive editor, surface the error so a curator supplies a correct URI. Either way the manifest never ships an invalidrightsstring. - CSV vs. XML vs. API source. The resolved URI may arrive from a CSV export column, a LIDO
<rightsType>element, or a collections API field. Only the extraction differs; feed whatever you resolve intois_allowed_rightsand the same vocabulary gate applies. The actual manifest assembly is covered in generating IIIF v3 manifests with Python.
Validation
Prove the two invariants that matter — an out-of-vocabulary value can never reach rights, and the requiredStatement serializes as valid language maps — with an assert-based test that needs no viewer:
# test_manifest_rights.py -> run with: pytest -q test_manifest_rights.py
import pytest
from pydantic import ValidationError
def test_cc_and_rightsstatements_uris_are_accepted():
for uri in [
"http://creativecommons.org/licenses/by/4.0/",
"https://creativecommons.org/publicdomain/zero/1.0/",
"http://rightsstatements.org/vocab/InC/1.0/",
"https://rightsstatements.org/vocab/NoC-US/1.0/",
]:
assert ManifestRights(rights=uri).rights.endswith("/")
def test_free_text_rights_is_rejected():
with pytest.raises(ValidationError):
ManifestRights(rights="All rights reserved")
def test_arbitrary_local_uri_is_rejected():
with pytest.raises(ValidationError):
ManifestRights(rights="https://collections.example.org/license/17")
def test_missing_trailing_slash_is_normalized():
mr = ManifestRights(rights="https://creativecommons.org/licenses/by/4.0")
assert mr.rights == "https://creativecommons.org/licenses/by/4.0/"
def test_required_statement_carries_multiple_lines():
rs = build_required_statement(["Provided by Example Museum.", "CC BY 4.0."])
dumped = rs.model_dump()
assert dumped["label"] == {"en": ["Attribution"]}
assert dumped["value"]["en"] == ["Provided by Example Museum.", "CC BY 4.0."]
def test_rights_may_be_omitted_but_statement_kept():
block = ManifestRights(required_statement=build_required_statement(["Anon lender."]))
out = block.apply({"type": "Manifest"})
assert "rights" not in out # omitted, not invented
assert out["requiredStatement"]["value"]["en"] == ["Anon lender."]A green run confirms that only CC and RightsStatements.org URIs survive into rights, that free text and local URIs raise at the boundary, that trailing-slash noise canonicalizes, that multiple credit lines land in one language map, and that omitting rights leaves a valid attribution-only block.
Standards Alignment
The rights and requiredStatement properties are defined by the IIIF Presentation API 3.0: rights “must be drawn from the set of Creative Commons license URIs, the RightsStatements.org rights statement URIs, or those added via the extension mechanism,” and requiredStatement is a label/value pair that clients “must display” — a presentation obligation, not an access restriction. The allowed license identifiers are the canonical URIs published by Creative Commons, and the copyright-status statements are those minted by RightsStatements.org; the twelve standardized statement codes cover In Copyright, No Copyright, and Other states. Selecting which URI a given work warrants is a rights-modeling decision handled in the rights metadata and licensing automation section — see mapping Creative Commons licenses to IIIF rights and mapping RightsStatements.org to collection fields — while this page is solely concerned with encoding that decision correctly in the manifest.
FAQ
Can I put my institution’s own license URL in the rights property?
No. IIIF Presentation 3.0 constrains rights to the Creative Commons and RightsStatements.org vocabularies specifically so any client can interpret the value without dereferencing it. A local URL — even one that resolves to a real rights page — is non-conformant. If your source value does not map to those vocabularies, omit rights entirely and carry the human-readable terms in requiredStatement instead.
Does requiredStatement stop unauthorized users from seeing the image?
No. requiredStatement is a display obligation: a conforming viewer must show the attribution, but it never withholds content. Access control lives upstream — you return a restricted or absent manifest, or the Image API refuses the tile request. Pushing an embargo condition into requiredStatement leaves the asset fully readable behind a notice, which is exactly the failure to avoid.
How do I credit several parties in one manifest?
A resource may hold only one requiredStatement, so put each credit line as a separate string in the value language map’s array under a single shared label such as Attribution. Viewers render array entries on separate lines. Repeating the property or nesting multiple statements is invalid.
What rights URI do I use for an in-copyright work with no open license?
Use a RightsStatements.org statement — commonly InC (In Copyright), or a more specific variant like InC-EDU or the orphan-oriented InC-RUU — never a Creative Commons URI, since CC URIs assert a license the rightsholder has actually granted. Reserve the Creative Commons Public Domain Mark and CC0 for public-domain and waived-rights material respectively.
Related
- Building IIIF Presentation Manifests — parent manifest-assembly stage
- Generating IIIF v3 Manifests with Python — the manifest skeleton this block attaches to
- Mapping Creative Commons Licenses to IIIF Rights — choosing the CC URI
- Mapping RightsStatements.org to Collection Fields — choosing the copyright-status URI
- IIIF Image Delivery & Manifest Generation — the delivery section overview