Skip to content
Blog

Token Gates

Token gates let you lock down content, features, or whole pages based on what a user holds in their wallet. The user signs a message to prove they own the wallet, and Tribe checks their on-chain balance. Nothing gets transferred. It’s just a read: do you hold enough tokens or not?

The flow is based on the x402 “Payment Required” protocol. Everything goes through one GET endpoint.

  1. Create a gate in the Tribe dashboard under your site’s settings. Set the token mint, symbol, decimals, and the minimum amount a user needs to hold.

  2. Hit the endpoint with no wallet. You’ll get back a 402 with the gate requirements and the message format the user needs to sign.

  3. Have the user sign the message, then call the same endpoint again with their wallet address, the signature, and a timestamp as query params.

  4. Tribe checks it all. It verifies the Ed25519 signature, makes sure the timestamp is fresh (within 5 minutes), looks up the wallet’s token balance on Solana, and returns 200 if they’re good or 403 if they’re short.

One endpoint handles everything:

GET /gate/:gateId

Hit the endpoint with no query params:

const res = await fetch(
`https://api.tribe.utopian.build/gate/${gateId}`
);
// HTTP 402
const data = await res.json();

The 402 response tells you what the gate needs:

{
"type": "token-gate",
"version": "1",
"gate": {
"id": "cuid_abc123",
"name": "Premium Access"
},
"requirement": {
"chain": "solana",
"tokenMint": "EPjFWdd5AufqSSqeM2qN1xzybapC8G4wEGGkZwyTDt1v",
"tokenSymbol": "USDC",
"tokenDecimals": 6,
"minAmount": "100"
},
"auth": {
"method": "ed25519",
"message": "Access gate <gateId>\\nWallet: <address>\\nTimestamp: <unix_seconds>",
"params": "?wallet=<address>&signature=<base58_sig>&timestamp=<unix_seconds>"
},
"message": "This resource requires holding at least 100 USDC."
}

Build the message string, sign it with the wallet, and call the same endpoint with wallet, signature, and timestamp as query params:

import nacl from "tweetnacl";
import { Keypair } from "@solana/web3.js";
const wallet = keypair.publicKey.toBase58();
const timestamp = Math.floor(Date.now() / 1000).toString();
// Build the exact message the server expects
const message = `Access gate ${gateId}\nWallet: ${wallet}\nTimestamp: ${timestamp}`;
const messageBytes = new TextEncoder().encode(message);
// Sign with Ed25519
const signatureBytes = nacl.sign.detached(messageBytes, keypair.secretKey);
const signature = encodeBase58(signatureBytes); // base58-encoded
const res = await fetch(
`https://api.tribe.utopian.build/gate/${gateId}` +
`?wallet=${wallet}&signature=${signature}&timestamp=${timestamp}`
);
StatusMeaningWhen
402Payment RequiredNo wallet provided, returns gate requirements
401UnauthorizedMissing, invalid, or expired signature
200AllowedSignature checks out and balance is sufficient
403ForbiddenSignature is valid but the wallet doesn’t hold enough

200 response (they’re in):

{
"allowed": true,
"gate": { "id": "cuid_abc123", "name": "Premium Access" },
"wallet": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"balance": "250.5",
"required": "100",
"tokenSymbol": "USDC"
}

403 response (not enough tokens):

{
"allowed": false,
"gate": { "id": "cuid_abc123", "name": "Premium Access" },
"wallet": "7xKXtg2CW87d97TXJSDpbD5jBkheTqA83TZRuJosgAsU",
"balance": "42",
"required": "100",
"deficit": "58",
"tokenSymbol": "USDC",
"message": "Wallet holds 42 USDC but 100 is required."
}

A full component that fetches gate requirements, asks the user to sign, and checks access:

import { useWallet } from "@solana/wallet-adapter-react";
import nacl from "tweetnacl";
import { useState, useEffect } from "react";
function TokenGatedContent({ gateId, children }) {
const { publicKey, signMessage } = useWallet();
const [state, setState] = useState<
"loading" | "needs-wallet" | "allowed" | "denied"
>("loading");
const [requirement, setRequirement] = useState(null);
const [deficit, setDeficit] = useState(null);
useEffect(() => {
checkGate();
}, [publicKey]);
const checkGate = async () => {
setState("loading");
if (!publicKey || !signMessage) {
// No wallet connected yet, just grab the requirements
const res = await fetch(
`https://api.tribe.utopian.build/gate/${gateId}`
);
const data = await res.json();
setRequirement(data.requirement);
setState("needs-wallet");
return;
}
// Wallet connected: sign and verify
const wallet = publicKey.toBase58();
const timestamp = Math.floor(Date.now() / 1000).toString();
const message = `Access gate ${gateId}\nWallet: ${wallet}\nTimestamp: ${timestamp}`;
const messageBytes = new TextEncoder().encode(message);
const signatureBytes = await signMessage(messageBytes);
const signature = encodeBase58(signatureBytes);
const res = await fetch(
`https://api.tribe.utopian.build/gate/${gateId}` +
`?wallet=${wallet}&signature=${signature}&timestamp=${timestamp}`
);
const data = await res.json();
if (res.status === 200) {
setState("allowed");
} else {
setDeficit(data.deficit);
setState("denied");
}
};
if (state === "loading") return <p>Checking access...</p>;
if (state === "needs-wallet") {
return (
<div>
<p>
Connect a wallet holding at least {requirement.minAmount}{" "}
{requirement.tokenSymbol} to access this content.
</p>
</div>
);
}
if (state === "denied") {
return (
<div>
<p>
Your wallet needs {deficit} more {requirement?.tokenSymbol} to
unlock this content.
</p>
</div>
);
}
return children;
}
  • Premium content like articles, videos, or downloads gated by token holdings
  • Feature unlocks for users who hold a specific token
  • NFT-gated access by setting a minimum balance of 1 for a given mint
  • DAO-gated features that only governance token holders can use