A wallet login, an identity record, and an NFT entitlement can each work in isolation while the access check between them fails. For a CTO facing a build-or-buy decision, I would build that access check first and buy neither an identity platform nor tokenization rails until it passes a local end-to-end test. The pilot is small enough to expose integration costs that procurement demos can hide.
A single access decision is the right starting boundary
Give the pilot one job: an authenticated account receives a nontransferable token, and an API grants access only while that token remains valid. Keycloak handles account login through OpenID Connect (OIDC); Sign-In with Ethereum, specified by ERC-4361, proves control of the wallet. Anvil supplies a local EVM chain, and an ERC-721 contract records the entitlement. The application, rather than the chain, keeps the account-to-wallet mapping because publishing that relationship would make account activity easier to correlate.
Start with one Keycloak realm, one OIDC client using Authorization Code with PKCE, one API, and one Anvil instance. After the API validates an OIDC ID token against the realm’s JSON Web Key Set, it issues a fresh SIWE challenge. The signed message must name the expected domain, wallet address, URI, nonce, chain ID, and expiration time; checking only the signature would let a valid signature from another session pass. Use viem’s verifySiweMessage for the signature check, then store the Keycloak issuer and subject alongside the verified wallet address. Do not treat an email address as the account key, because it can change or be reassigned.
Set the local chain ID to 31337, Anvil’s development default, and include it in both the SIWE message and token lookup. Start with a 15-minute challenge lifetime as a value to tune against login failures; the server must also consume each nonce once, because expiry alone does not stop replay inside that window. Reject a second wallet binding for the same account unless the user completes an explicit replacement flow.
If the product needs a decentralized identifier at this stage, represent the verified wallet as a did:pkh reference using the CAIP-10 account form, including the chain namespace and chain ID. That reference identifies a key-controlled account; it does not establish a person’s identity. When CTOs Should Build or Buy Decentralized Identity frames identity as a sourcing choice, but I would defer that choice here because a DID provider cannot repair a weak account-to-wallet binding.
The first contract should make revocation testable, not token trading possible
Install Foundry, initialize a project with forge init access-pilot –no-git, and install OpenZeppelin Contracts 5.0.2 with forge install OpenZeppelin/openzeppelin-contracts@v5.0.2 –no-git. Put this contract in src/AccessReceipt.sol. The pinned library release is a reproducibility choice, not a claim that the pilot is production-audited.
pragma solidity ^0.8.24;
import "openzeppelin-contracts/contracts/token/ERC721/ERC721.sol";
contract AccessReceipt is ERC721 {
address public immutable issuer; uint256 public nextId;
mapping(uint256 => bool) public revoked;
constructor() ERC721("AccessReceipt", "AR") { issuer = msg.sender; }
function mint(address to) external returns (uint256 id) {
require(msg.sender == issuer, "issuer"); id = ++nextId; _safeMint(to, id);
}
function revoke(uint256 id) external {
require(msg.sender == issuer && _ownerOf(id) != address(0), "issuer/token"); revoked[id] = true;
}
function _update(address to, uint256 id, address auth) internal override returns (address) {
require(_ownerOf(id) == address(0), "nontransferable"); return super._update(to, id, auth);
}
}
Run forge build, then start anvil –chain-id 31337 in a separate terminal. Deploy with forge create src/AccessReceipt.sol:AccessReceipt –rpc-url http://127.0.0.1:8545 –private-key <anvil-test-private-key>. Use a private key printed by Anvil only for this disposable local chain. After the API has verified both login and wallet control, call mint(address) for that wallet using cast send or viem’s writeContract. Record the returned token ID with the account binding, contract address, and chain ID; a token ID without its contract and chain is ambiguous.
For each protected API request, verify the OIDC session, locate its bound wallet and token, and read ownerOf(tokenId) and revoked(tokenId) through viem. Access requires the expected owner and a false revocation value. Have the issuer call revoke(tokenId), then repeat the same request: it should be denied without changing the user’s OIDC account. The nontransferable override prevents a holder from selling the entitlement, while explicit revocation lets support remove access without claiming to control the holder’s wallet.
I would not put names, emails, or hashes of low-entropy identity fields in token metadata, because public records persist and predictable values can be guessed. The contract intentionally stores only token state. It also centralizes mint and revoke authority in the issuer address; that is acceptable for this pilot because the test is whether the application enforces a decision, not whether governance is decentralized.
The acceptance test should cross every trust boundary
A successful mint is not the acceptance test. Automate the sequence: log in through Keycloak, request a SIWE nonce, sign it with a test wallet, bind that wallet, mint, call the protected endpoint, revoke, and call it again. The observed result must change from allowed to denied after revocation. Record the chain block used for each access decision so an engineer can distinguish stale RPC data from an authorization bug.
Add negative cases before inviting a vendor: reuse a nonce, alter the SIWE domain, switch the stated chain, sign with a different wallet, present an ID token with the wrong audience, and try to transfer the receipt. Each should fail for a specific reason. An acceptance target of 20 consecutive end-to-end runs is a proposed release gate, not a measured reliability rate; it is large enough to expose intermittent session, indexing, or RPC errors without pretending to predict production availability. Treat zero successful revocation-bypass attempts in those tests as a separate security gate, because an average pass rate would conceal the failure that matters most.
Do not make an NFT indexer authoritative for the first version. Read the contract at a specified block through a JSON-RPC endpoint, because an asynchronously updated index can still report access after a revocation transaction has landed. The trade-off is additional RPC latency on the request path. If that latency becomes unacceptable, introduce a cache with an explicit maximum age and invalidate it on confirmed revocation; measure the delay between the revoke receipt and the first denied request before choosing that age.
Keep identity credentials outside this access decision until a requirement calls for portable attestations. If one does, issue a W3C Verifiable Credentials Data Model 2.0 credential separately and verify its issuer, proof, subject, and status at the API boundary. An ERC-721 ownership check is not a verifiable credential proof, because ownership says nothing about who attested to an off-chain fact. Conversely, a credential should not silently grant access after the application’s token has been revoked.
Buy the component that fails the trial, not the architecture diagram
Run two implementations against the same acceptance test. An in-house Foundry, OpenZeppelin, and viem stack wins when the entitlement contract is small, engineers can operate issuer keys and RPC failover, and application-specific authorization changes frequently. Its cost is staff time for contract review, deployments, key custody, incident response, and chain monitoring. A managed tokenization provider wins when those operational duties cannot be staffed or when supported chains and transaction handling are already contractual requirements. Its cost is vendor fees, API constraints, dependency on its key and indexing policies, and a migration path that must be tested rather than assumed.
Ask the provider to execute the same bind–mint–allow–revoke–deny sequence in a sandbox. Require evidence of which address can revoke, how quickly a confirmed revocation reaches its read API, what happens during an RPC outage, and whether token IDs and events remain readable through standard Ethereum JSON-RPC. Build or buy NFT tokenization rails CTO decision points is a useful procurement prompt, but the sandbox should decide the purchase because a feature comparison cannot show whether revocation propagates through this API.
Apply the same separation to identity procurement. Keycloak and SIWE can remain in-house while credential issuance is bought, or a managed login can coexist with an in-house contract, because the OIDC subject, verified wallet, and chain-specific token have explicit interfaces. If a vendor cannot export the binding data and verification material needed to reproduce an access decision, its convenience carries a migration cost. Document that cost beside its quoted price.
Before production, replace Anvil keys, review issuer custody, arrange independent contract review, and rehearse lost-key and RPC-outage procedures. These are exit conditions for a local pilot, not reasons to expand it prematurely: none of them changes what the first access decision must prove.
The next step is to make one denial observable
Create the local realm, deploy the receipt contract, and make a protected endpoint return its token ID, chain ID, decision block, and denial reason in development logs. Then revoke a token and watch the next request fail. If that result is unreliable, capture the failing boundary before scheduling vendor demos; it gives every build-or-buy proposal a concrete defect to solve.


