NFT-Based Subscriptions: Building a Time-Limited Access Pass with ERC-721

What if a Netflix-style subscription lived on the blockchain — as an NFT you actually own, can renew, and can even transfer to someone else? That is exactly what the NFTSubscription contract in this project does. In under 50 lines of Solidity, it turns an ERC-721 token into a 30-day access pass that users buy, renew, and that any dApp can check with a single view call.

This article walks through the full contract (nftacess.sol), explains every important code block, and closes with the design trade-offs and security considerations you should know about.


1. The Idea: NFTs as Subscriptions

Traditional subscriptions (Web2) live in a company’s database: you pay, a row gets updated, and your access disappears the moment the company decides so. An NFT subscription flips this model:

  • Ownership — the pass is a token in your wallet, not a row in someone’s database.
  • Transferability — because it’s a standard ERC-721, you can sell or gift the remaining subscription time.
  • Composability — any other contract or frontend can verify your access on-chain with one call, no API keys required.
  • Expiry — each token carries a timestamp; once it passes, access is denied, but the NFT itself survives and can be renewed.

The mental model: the NFT is the membership card, and the contract stamps an expiry date on the back of it.


2. Contract Overview

Here is the complete contract:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract NFTSubscription is ERC721 {

    uint256 public tokenId;
    uint256 public constant PRICE = 0.01 ether;
    uint256 public constant DURATION = 30 days;

    mapping(uint256 => uint256) public expiry;

    constructor() ERC721("Subscription NFT", "SUB") {}

    function subscribe() external payable {
        require(msg.value == PRICE, "Wrong payment");

        tokenId++;
        _mint(msg.sender, tokenId);

        expiry[tokenId] = block.timestamp + DURATION;
    }

    function renew(uint256 _tokenId) external payable {
        require(ownerOf(_tokenId) == msg.sender, "Not owner");
        require(msg.value == PRICE, "Wrong payment");

        if (expiry[_tokenId] < block.timestamp) {
            expiry[_tokenId] = block.timestamp + DURATION;
        } else {
            expiry[_tokenId] += DURATION;
        }
    }

    function isActive(uint256 _tokenId) external view returns (bool) {
        return expiry[_tokenId] > block.timestamp;
    }

    function withdraw() external {
        payable(msg.sender).transfer(address(this).balance);
    }
}

Four functions, one mapping, and OpenZeppelin’s battle-tested ERC-721 doing the heavy lifting. Let’s break it down piece by piece.


3. State Variables and Setup

import "@openzeppelin/contracts/token/ERC721/ERC721.sol";

contract NFTSubscription is ERC721 {

    uint256 public tokenId;
    uint256 public constant PRICE = 0.01 ether;
    uint256 public constant DURATION = 30 days;

    mapping(uint256 => uint256) public expiry;

    constructor() ERC721("Subscription NFT", "SUB") {}

What’s happening here:

  • import ERC721.sol — instead of writing the NFT standard from scratch, the contract inherits from OpenZeppelin’s audited implementation. This gives us _mint, ownerOf, transferFrom, approvals, and all standard events for free.

  • uint256 public tokenId — a simple counter that serves as the ID for the next minted pass. It starts at 0 and is incremented before each mint, so the first pass is token #1.

  • PRICE = 0.01 ether — the cost of one subscription period. Marked constant, so it is baked into the bytecode at compile time — reading it costs no storage access, and it can never be changed after deployment.

  • DURATION = 30 days — Solidity’s time units make this readable: 30 days literally compiles to 2,592,000 seconds. One payment buys one such period.

  • mapping(uint256 => uint256) public expirythe heart of the contract. It maps each token ID to a Unix timestamp of when that subscription expires. This is what turns a plain NFT into a time-limited pass.

  • constructor() ERC721("Subscription NFT", "SUB") — sets the collection name and symbol that wallets and marketplaces will display. Nothing else is needed at deployment.

:bulb: Key insight: the expiry lives in a mapping keyed by token ID, not by owner address. That means the remaining subscription time travels with the NFT when it is transferred — sell the token, and the buyer inherits whatever time is left.


4. Subscribing — Minting the Pass

function subscribe() external payable {
    require(msg.value == PRICE, "Wrong payment");

    tokenId++;
    _mint(msg.sender, tokenId);

    expiry[tokenId] = block.timestamp + DURATION;
}

This is the entry point for new users. Line by line:

  1. external payable — the function accepts ETH/CFX. Without payable, any transaction sending value would revert.

  2. require(msg.value == PRICE, "Wrong payment") — strict equality check. Sending too little or too much reverts. This keeps the accounting dead simple: there is never any change to refund.

  3. tokenId++ — increments the counter first, so IDs go 1, 2, 3, .... Each subscriber gets a unique, sequential pass.

  4. _mint(msg.sender, tokenId) — OpenZeppelin’s internal mint. It assigns the new token to the caller and emits the standard Transfer(address(0), msg.sender, tokenId) event, so indexers and wallets pick it up automatically.

  5. expiry[tokenId] = block.timestamp + DURATION — stamps the expiry: now + 30 days. From this moment, the pass is active.

Flow of a subscription:

User sends 0.01 ETH  ──►  subscribe()
                            │
                            ├─ payment == PRICE?  ── no ──► revert "Wrong payment"
                            │
                            ├─ mint NFT #N to user
                            │
                            └─ expiry[N] = now + 30 days   ✅ pass active

5. Renewing — Extending the Clock

function renew(uint256 _tokenId) external payable {
    require(ownerOf(_tokenId) == msg.sender, "Not owner");
    require(msg.value == PRICE, "Wrong payment");

    if (expiry[_tokenId] < block.timestamp) {
        expiry[_tokenId] = block.timestamp + DURATION;
    } else {
        expiry[_tokenId] += DURATION;
    }
}

Renewal is the most thoughtful part of the contract, because it handles two distinct situations correctly:

Guard checks

  • ownerOf(_tokenId) == msg.sender — only the current holder can renew. As a bonus, ownerOf reverts for non-existent tokens, so you cannot renew a pass that was never minted.
  • msg.value == PRICE — same strict-payment rule as subscribe.

The expired vs. active branch

This if/else is the detail that prevents users from being cheated — in either direction:

Scenario Logic used Why
Subscription already expired expiry = now + 30 days The clock restarts from today. Without this, a user who let their pass lapse for 6 months would pay 0.01 ETH and get an expiry still in the past (old expiry + 30 days) — paying for nothing.
Subscription still active expiry += 30 days Time is stacked on top of the remaining balance. Renewing 5 days early doesn’t cost you those 5 days — you end up with 35 days total.

This is exactly how well-behaved Web2 subscriptions work, reproduced in four lines of Solidity. A user can also call renew several times in a row to prepay multiple months at once.


6. Checking Access — The Gatekeeper Function

function isActive(uint256 _tokenId) external view returns (bool) {
    return expiry[_tokenId] > block.timestamp;
}

One line, but it is the entire utility of the system. isActive answers: "is this pass still valid right now?"

  • It’s a view function — reading it via RPC costs no gas, so a frontend can call it on every page load for free.
  • For a token that was never minted, expiry[_tokenId] is 0, which is always <= block.timestamp, so it safely returns false.
  • Note the deliberate separation of concerns: an expired NFT is not burned. The user keeps the token; it just stops granting access until renewed. Ownership and validity are independent.

7. Withdrawing Funds

function withdraw() external {
    payable(msg.sender).transfer(address(this).balance);
}

All subscription payments accumulate in the contract’s balance. withdraw sends the entire balance to the caller.

:warning: Important: as written, this function has no access controlanyone, not just the deployer, can call it and drain the collected funds. This is the single most critical issue in the contract and must be fixed before any real deployment. See the next section.


8. How a dApp Uses This

The beauty of this pattern is how simple integration becomes. A backend or frontend gates content with two read-only calls:

// ethers.js example
const sub = new ethers.Contract(CONTRACT_ADDRESS, ABI, provider);

async function hasAccess(userAddress, tokenId) {
    const owner = await sub.ownerOf(tokenId);
    const active = await sub.isActive(tokenId);
    return owner.toLowerCase() === userAddress.toLowerCase() && active;
}

And another smart contract can gate its own functions the same way:

interface INFTSubscription {
    function ownerOf(uint256 tokenId) external view returns (address);
    function isActive(uint256 tokenId) external view returns (bool);
}

contract PremiumFeature {
    INFTSubscription public sub;

    modifier onlySubscriber(uint256 tokenId) {
        require(sub.ownerOf(tokenId) == msg.sender, "Not pass holder");
        require(sub.isActive(tokenId), "Subscription expired");
        _;
    }
}

No OAuth, no API keys, no centralized session store — the chain is the auth server.


9. Security Notes and Improvements

This contract is a great learning skeleton, but before production use, address the following:

:red_circle: Critical

  1. Unprotected withdraw — anyone can steal the funds. Fix by inheriting Ownable:
import "@openzeppelin/contracts/access/Ownable.sol";

contract NFTSubscription is ERC721, Ownable {
    constructor() ERC721("Subscription NFT", "SUB") Ownable(msg.sender) {}

    function withdraw() external onlyOwner {
        (bool ok, ) = payable(owner()).call{value: address(this).balance}("");
        require(ok, "Transfer failed");
    }
}

Note this also replaces transfer with call. transfer forwards only 2,300 gas, which can break withdrawals to smart-contract wallets (e.g., multisigs); call is the modern recommended pattern.

🟡 Recommended

  1. Emit eventssubscribe and renew change important state silently. Events like Subscribed(address user, uint256 tokenId, uint256 expiry) and Renewed(uint256 tokenId, uint256 newExpiry) make off-chain indexing and analytics vastly easier.

  2. One user, many passes — nothing stops a single wallet from minting multiple subscriptions. Depending on the product this may be fine (passes are tradable inventory) or undesirable (add a balanceOf(msg.sender) == 0 check).

  3. Fixed price foreverPRICE is constant, so it can never be adjusted for market conditions. A production version might use an owner-settable price variable instead.

  4. Renewal by anyone? — currently only the owner can renew. Some designs deliberately allow anyone to renew a token (e.g., a company sponsoring an employee’s pass). That would just mean removing the ownerOf check — a product decision, not a bug.

  5. Consider EIP-5643 — there is an emerging standard, ERC-5643: Subscription NFTs, which formalizes exactly this pattern (renewSubscription, expiresAt, isRenewable). Adopting it makes the contract interoperable with tooling that already understands subscription NFTs.


10. Conclusion

NFTSubscription packs a complete subscription business model into a minimal ERC-721 extension:

Function Role
subscribe() Pay 0.01 ETH → mint pass valid 30 days
renew(tokenId) Pay 0.01 ETH → add 30 days (fairly, whether expired or active)
isActive(tokenId) Free on-chain access check for any dApp
withdraw() Collect revenue (must be access-controlled before deployment)

The core design lesson: decouple ownership from validity. The NFT never expires — only its access right does, tracked by a single mapping(uint256 => uint256). That one mapping is enough to turn any NFT collection into a renewable, transferable, on-chain subscription service.


Resources

GitHub repo: https://github.com/Vikash-8090-Yadav/cfxnftacess

Yt tutorial: https://youtu.be/_3ndWsD2klA

Conflux docs: https://doc.confluxnetwork.org/docs/