back to index

nft-gating virtual spaces

The premise is straightforward: instead of managing an invite list, you use a digital token as the key. Own the token, get in. Do not own it, stay out. One of our development partners, a cultural festival in Amsterdam, wanted exclusive virtual spaces that only ticket holders could access. Their tickets were NFTs. The question was whether we could use on-chain ownership as the access control mechanism.

Building it was more interesting than I expected, mostly because of what happens when someone does not have access.

the data model

A space owner can set up a gate on their virtual space. The gate is a separate entity, linked to the space by UUID. The model stores the minimum configuration needed to check ownership on-chain:

@Entity
@Table(name = "space_pro_gate_nft")
public class SpaceProGateNFT {
    @Column(name = "contract_address", length = 42)
    private String contractAddress;

    @Enumerated(EnumType.STRING)
    @Column(name = "chain")
    private Chain chain;          // ETHEREUM or POLYGON

    @Enumerated(EnumType.STRING)
    @Column(name = "contract_type")
    private ContractType contractType;  // erc721 or erc1155

    private String tokenName;
    private String tokenId;
    private String purchasingLink;
    private boolean isActive;
    private UUID spaceUuid;
}

Three fields drive the gate logic: contractAddress, chain, and contractType. The tokenId field only matters for ERC-1155 tokens, where multiple people can hold different editions under the same contract. For ERC-721 (unique one-of-one tokens), any token from the collection qualifies. The distinction between ERC-721 and ERC-1155 determined whether we checked "owns any token from this contract" or "owns this specific token ID."

The purchasingLink field stores a URL where a blocked user can acquire the required token. This is not an afterthought. It is part of the gate configuration because the gate is useless without a path forward for people who do not have access yet.

the wallet connection

The wallet was an integration, not part of identity. Users connected wallets through MetaMask on the web client. The VR headset could not run MetaMask, so wallet connection happened on the web dashboard before entering VR. A user could use Ravel without ever connecting a wallet. It only mattered when entering a token-gated space.

the gate check

When a visitor tries to enter a gated space, the backend runs through a decision tree:

public SpaceProGateNFTGetDto getNFTGateForSpaceOnEntry(UUID spaceUuid) {
    SpaceProGateNFT gate = findNFTGateBySpaceUuid(spaceUuid, true);
    if (gate == null) return null;  // no gate, open access

    UUID userUuid = UUID.fromString(
        authenticationFacade.getAuthentication()
            .getPrincipal().toString()
    );

    SpaceProGateNFTGetDto response = gateMapper
        .spaceProGateNFTToSpaceProGateNFTGetDto(gate);

    UserIntegrationWallet wallet =
        web3Service.findUserIntegrationWalletByUserUuid(userUuid);

    if (wallet != null) {
        response.setHasWallet(true);
        response.setHasAccess(
            validateIfUserOwnsNFT(gate, wallet)
        );
    } else {
        response.setHasWallet(false);
        response.setHasAccess(false);
    }
    return response;
}

The response DTO carries two boolean fields alongside the gate configuration: hasWallet and hasAccess. The client receives a structured verdict, not a binary yes/no. Three distinct states: no wallet connected, wallet connected but no token, and access granted. The Unity client renders a different screen for each.

the ownership check

The ownership verification delegates to Moralis, a blockchain indexing service. Rather than querying the chain directly, we query Moralis's indexed database:

public boolean validateIfUserOwnsNFT(
    SpaceProGateNFT gate,
    UserIntegrationWallet wallet
) {
    MoralisGetNFTs userNFTs;

    if (gate.getChain() == Chain.ETHEREUM) {
        userNFTs = web3Service.getEthNFTs(
            wallet, gate.getContractAddress());
    } else {
        userNFTs = web3Service.getPolygonNFTs(
            wallet, gate.getContractAddress());
    }

    if (gate.getContractType() == ContractType.erc721) {
        return userNFTs.result.size() > 0;
    }

    if (gate.getContractType() == ContractType.erc1155) {
        return userNFTs.result.stream()
            .anyMatch(nft ->
                nft.token_id.equals(gate.getTokenId()));
    }

    return false;
}

The gate check is fast because we query an indexed database, not the blockchain directly on every request. Moralis handles the indexing. The tradeoff is a small delay between on-chain changes and index updates, typically under a minute. For event access (not financial transactions) that lag was acceptable.

the design decisions

The three-field configuration (contract address, chain, token standard) is deliberately minimal. We considered adding more flexibility: specific token IDs for ERC-721, minimum balance requirements, multi-contract gates. We kept it simple for the first version. Most use cases were either "own any token from this collection" or "own this specific edition." The minimal model covered them cleanly.

We also had to decide where enforcement lived. The backend returns a verdict. The Unity client enforces it. That is intentional. The client handles the UI state (show the "you need this token" screen, display the purchase link), while the backend handles the logic. Clean separation. The backend is the authority on access. The client is the authority on presentation.

Only one gate can be active per space at a time. Creating a new gate deactivates any existing one. This constraint simplified the data model and avoided confusing "AND/OR" logic for multiple gate conditions. A space either has one active gate or none.

the part that actually matters

Here is the thing about access gates: most of your users will not have access. If you just show them a locked door, you have failed them.

The UX for the person without the token is as important as the gate itself. They need to understand why they are blocked, what they need, and exactly how to get it. The structured response from the backend was designed with this in mind. The purchasingLink field, the tokenName, the hasWallet distinction: all of it exists to give the client enough information to guide a blocked user forward rather than just rejecting them.

This is a pattern that generalizes well beyond NFTs. Whenever you have conditional access, invest as much in the rejection path as in the approval path. Show what is needed, why, and how to get it. The gate is not the feature. The path through the gate is.


I was Technical Director and co-founder at Ravel from 2021 to October 2022.