ERC-8291: SIWE-Gated NFT Media URI

ERC-8288 Draft: SIWE-Gated NFT Media URI

Hi everyone,

I would like feedback on a new ERC draft: SIWE-Gated NFT Media URI.

The core use case is simple: a wallet sees private_media_uri in public NFT metadata, asks an
authorized account to sign a SIWE challenge, then displays the returned private image instead of the public preview. Existing clients that do not implement the proposal can keep rendering the
public metadata.

Summary

The proposed metadata addition is one new public metadata field:

{
  "name": "Example NFT",
  "description": "Public description safe for unauthenticated clients.",
  "image": "https://example.com/public-preview.png",
  "private_media_uri": "https://media.example.com/eip-private-nft-media/8453/0xabc0000000000000000000000000000000000000/42"
}

ERC-721 tokens would expose this JSON through tokenURI(tokenId). ERC-1155 tokens would expose it through the existing metadata URI returned by uri(id).

private_media_uri is only a discovery pointer. It must be an absolute HTTPS URI, must not include a fragment or embedded userinfo, and must not contain secrets, bearer tokens, account-specific secrets, or private media payloads. Existing clients can ignore it and continue rendering the public image.

EIP

Demo site: https://eip-private-nft-media.vercel.app/
Source / reference implementation: GitHub - Fairmint/eip-private-nft-media ¡ GitHub

Wallet Flow

The primary wallet use case is:

  1. The wallet reads normal ERC-721/ERC-1155 metadata and renders the public image.
  2. The wallet sees private_media_uri and requests it.
  3. The resource server returns 401 Unauthorized with WWW-Authenticate: SIWE and a
    challenge_uri.
  4. The wallet asks the owner, holder, or another authorized account to sign the SIWE message.
  5. The wallet retries the protected URI with the signed SIWE proof.
  6. The resource server verifies the signature and token authorization, then returns private JSON
    metadata containing image.
  7. The wallet renders that private image as the unlocked NFT media.

The intent is that wallet providers can implement this once: detect private_media_uri, run the
SIWE unlock flow, and replace the public preview with the private image.

SIWE Binding

The SIWE challenge binds the signature to the exact resource being requested. The challenge message must include the resource server domain, requested private media URI, token chain id, nonce, expiration, and a token-scoped SIWE resources entry:

eip155:{chainId}/{standard}:{contractAddress}/{tokenId}?account={account}&resource={privateMediaUri}

For example:

eip155:8453/erc721:0xabc0000000000000000000000000000000000000/42?account=0x1230000000000000000000000000000000000000&resource=https%3A%2F%2Fmedia.example.com%2Feip-private-nft-media%2F8453%2F0xabc0000000000000000000000000000000000000%2F42

The resource server verifies the SIWE message, signature, nonce, expiration, domain, URI, chain, contract, token id, account, and exact private media URI before serving content. EIP-1271 is used for contract-account signatures.

For ERC-721, the bound account must be the current ownerOf(tokenId). For ERC-1155, the bound account must have balanceOf(account, id) > 0. Resource servers may also support approved operators or delegates if their own content policy treats those relationships as authorization.

Additional Resources

The same exact-resource binding can protect more than the unlocked NFT image. For example, a holder could authorize a third-party site to fetch third-party-view.json without granting access to
private-image.png or every protected resource for the token. The proposal does not define a global delegation registry; it only defines how a protected URI is discovered and how SIWE authorization is scoped to that exact URI.

The draft builds on existing ERC-721/ERC-1155 metadata, SIWE, and EIP-1271 contract-account
signatures. It does not add a new on-chain interface.

I would especially appreciate feedback on whether private_media_uri is clear, whether the SIWE
challenge flow fits wallet and media-client expectations, and whether this is small enough for
wallets, indexers, NFT projects, and media servers to implement consistently.

Thanks for reading.

2 Likes

There was an issue with the demo site that has now been fixed. Let me know if there are any other issues or questions.

1 Like

Thanks for opening this. The exact-resource SIWE binding is the strongest part of the draft: domain, chain, contract, tokenId, account, nonce, expiration, and the exact private media URI all inside the signed message means a captured proof is useless anywhere else, and the 401 plus WWW-Authenticate: SIWE handshake keeps the whole thing web-native. The signer-authorization rules and the SHOULD re-check before every protected response are the right calls. On your third question: yes, this is small enough for wallets to implement consistently, and one adjustment would keep it that small while widening what it can serve.

The flow establishes two different facts, and only one of them needs to be normative for wallets. The SIWE proof establishes identity: the requester controls, or is authorized by, the bound account. The resource server’s on-chain read establishes authorization: the bound account stands in an approved relationship to the token. Your Rationale already separates these, and calls owner and holder access “the required baseline.” The Specification writes it as more than a baseline. “For ERC-721, the bound account MUST equal ownerOf(tokenId)” is a ceiling as well as a floor, and it closes off any account whose claim to the media runs through something other than the token itself.

This is a different axis from the delegation text you already have. Both Protected Resource Flow and Verification Rules let the SIWE address differ from the account, which covers operators and delegates signing on the owner’s behalf. What is fixed is the account itself. A holder whose entitlement comes from somewhere other than that tokenId has no seat in the binding at all.

Suggestion: keep the grant floor, relax the binding ceiling.

  • The grant floor stays: resource servers MUST authorize the current ownerOf(tokenId) for ERC-721, and any account with balanceOf(account, id) > 0 for ERC-1155 (for 1155 this states the grant’s referent explicitly, since “the bound account” stops implying holder once the binding relaxes).
  • The bound account SHOULD be that owner or holder, and servers MAY recognize additional relationships over the (account, token) pair under an explicit, server-defined policy, which is the same standard your approval and delegation text already sets.
  • The Verification Rules bullet “token authorization for the bound account at the time of the request” becomes “token authorization, or explicit server policy, for the bound account at the time of the request.”

Wallet-side, the default path is unchanged: bind the connected address, take the challenge, sign, render. Owner-signature-unlocks stays guaranteed. The one real change is that a wallet can no longer rule out an unlock from a local ownerOf call, so it attempts and reads the 401. That is the behavior it needs anyway once operator and delegate signing is live.

What it buys: a server whose content policy reaches past the token itself, for example private media across a collection that unlocks for holders of the artist’s separate membership token, becomes a conforming policy behind the same discovery pointer, the same challenge, and the same signature, instead of a fork of the flow. The resources grammar, the wallet path, and the indexer path are all untouched.

Douglas

1 Like

Thanks Douglas — this led to real changes. Full diff: commit.

TL;DR: the wording problems you flagged are fixed, and the bigger ask — access that runs
through something other than the token being viewed — is now supported two ways. The token
binding rule itself stays strict, because with these additions it doesn’t need loosening.

Fixed: the Rationale/Specification mismatch (the Rationale side), your ERC-1155
restatement is in (tweaked so the floor doesn’t read as a promise to serve no matter what —
takedowns happen), and verification now says exactly what the binding’s contract and token id
get compared against — the gap your 1155 parenthetical pointed at. Details in the diff.

Added:

  1. Gating tokens. The bound token can now differ from the token whose metadata points at
    the media. Your membership example works directly: the server binds the membership pass
    with resource= set to the artwork’s private media URI, and the on-chain check runs
    against the pass, exactly as the MUST is written. Owners of the token being viewed are
    still always in, wallets SHOULD show the gating token when it differs, and since
    chain-id follows the bound token, cross-chain gates (mainnet membership, Base media)
    work too — there’s a worked example of exactly that in the spec now.

  2. A policy form. For access that isn’t a token holding at all — allowlists, attestations,
    balance thresholds — there’s now a third binding form that says so:
    policy:{policyId}?account=..&resource=... Same handshake, challenge, and
    nonce/domain/uri rules; the server evaluates its own policy.

Not changed: for the erc721/erc1155 forms, the bound account MUST hold the bound
token. That rule was never about access control — it’s what keeps a token binding a claim
anyone can check from the message, signature, and chain state. Policy-based access is
conforming now; it just says what it is instead of borrowing token syntax.

If you have a use case that still doesn’t fit any of these three forms, share it — happy to
make more changes to cover it.

1 Like

This lands better than what I asked for. I proposed relaxing the token binding; splitting the job into gating tokens plus an honest policy form is the cleaner cut. The token forms keep the property that made them worth being strict about, a claim anyone can recheck from the message, signature, and chain state, and grants that were never chain-checkable stop borrowing syntax that implies they are. The two-sided rationale reads exactly right now.

The details went in carefully too: the expected-binding comparison pins verification to something concrete, the content-refusal carve-out is the right fix to my floor wording, and the getApproved-only-when-owner guard plus nonce-to-challenge binding are hardening I did not ask for but would have on a second pass.

On your closing question, one seam is left. Entitlements that are fully readable from chain state but are not a single 721 or 1155 holding have nowhere to go except the policy form: a fungible balance threshold, a minimum quantity of an 1155 id (the token form tests only balanceOf > 0), an on-chain attestation, or two holdings that must both be true, since the message carries exactly one binding entry. Each of those evaluates against chain state the same way a token binding does, but it lands behind an opaque policyId in a form the spec says makes no on-chain claim, so a checkable claim reads as an asserted one, which is the distinction the revised Rationale is built on. Nothing needs to change today; the reserved standard values look like the natural place for that to grow later.

Nothing blocking from me.

Douglas

This has been a genuinely useful discussion — you said nothing needed to change today, but
two of your four cases turned out to be easy wins, so they’re in. Diff since the last
commit you reviewed: 6edc662…e0450a2d.

ERC-20 and ERC-1155 are common enough that they belong in the spec directly. ERC-20 balance
gates are now a binding form (no token id, threshold in base units), and ERC-1155 gets a
minAmount threshold. We ended up requiring the threshold on both instead of defaulting to
1 — one simple rule, and whoever publishes the gate decides whether holding dust counts.
minAmount=1 is just the old “any holder” rule, and ERC-721 stays plain ownership.

The other two (attestations, multi-token conditions) stay out for now — attestation
conventions aren’t settled yet, and multi-token conditions change the binding’s shape — but
the reserved-values path you suggested is now a real extension clause in the text rather
than a vague placeholder.

Two small things added along the way: contracts MAY emit ERC-4906 when the private
content changes — a refresh signal for wallets, never required — and an ERC-20 allowance
never grants access; it’s a spending approval, so signing for someone else takes explicit
delegation.

Let me know if you have more suggestions.

Both additions land, and requiring the threshold explicitly is the right call: absent-means-1 saves a few characters and costs an ambiguity, and the publisher of the gate is the right party to decide whether dust counts.

The extension clause came out stronger than the placeholder I was pointing at. Admitting a new standard value only with a deterministic verification procedure over on-chain state, the reads and the comparison named, bounded and executable by any verifier without server-defined input, and pushing everything open-ended to the policy form, draws the boundary in exactly the right place. That paragraph is what will keep future extensions honest.

Deferring attestations and multi-token conditions reads right, for your stated reasons, and neither is foreclosed: the clause takes an attestation once the conventions settle, and a composed entitlement changes the binding’s structure rather than one field’s meaning, so a future ERC is the right home for it. The allowance rule is the same instinct as the getApproved guard: an approval built for spending or transfer is not an access grant. This spec has gotten materially better in a week, and the revision trail shows it. Nothing further from me.

Douglas

2 Likes