Published 1/15/2026 · 15 min read
Security Considerations for Token-Gated Applications
A beginner’s guide to building secure NFT-based access control
Introduction
Token gating is a powerful way to control access to your application based on NFT ownership. Users prove they own a specific NFT, and your app grants them access to exclusive content, features, or communities.
But with great power comes great responsibility. A poorly secured token-gated app can expose your users to attacks, let unauthorized people slip through, or even drain your API budget. This guide walks through the security considerations we addressed while building TreehouseHQ’s workspace system.
Who is this for? Developers building their first token-gated application, or anyone curious about web3 security fundamentals.
Table of Contents
- The Golden Rule: Never Trust the Client
- Authentication vs Authorization
- Verifying NFT Ownership
- Protecting Your APIs
- Database Security with RLS
- Caching Safely
- Input Validation
- Rate Limiting
- CORS: Who Can Call Your APIs
- Security Headers
- Signature Verification
- Common Pitfalls
1. The Golden Rule: Never Trust the Client
This is the most important security principle in web development, and it’s especially critical for token gating.
The Problem
// DANGEROUS: Client-side NFT check
const hasNFT = await checkWalletForNFT(userWallet, collectionAddress);
if (hasNFT) {
showSecretContent();
}
What’s wrong here? The check happens in the user’s browser. A malicious user can:
- Open browser DevTools
- Modify the JavaScript
- Make
hasNFTalways returntrue - Access your gated content without owning the NFT
The Solution
Always verify on the server. Your server (or Edge Function) is code that runs on machines you control. Users can’t modify it.
// SAFE: Server-side NFT check (Edge Function)
const hasNFT = await verifyNFTOnServer(userWallet, collectionAddress);
// Server makes the API call, server returns the result
// User can't tamper with this
Our Implementation
We use Supabase Edge Functions for all NFT verification. The user’s browser sends a request, but the actual Vybe API call happens on Supabase’s servers:
User Browser → Edge Function → Vybe API → Edge Function → User Browser
↑
User can't access this
2. Authentication vs Authorization
These terms are often confused, but understanding the difference is crucial.
Authentication: “Who are you?”
Authentication proves the user’s identity. In our case:
- User connects their Solana wallet
- User signs a message with their private key
- Server verifies the signature matches the wallet address
- Now we know: “This user controls wallet ABC123…”
Authorization: “What can you do?”
Authorization determines what an authenticated user is allowed to do:
- Do they own the required NFT? → Can access the workspace
- Are they the workspace owner? → Can manage admins
- Are they on the allowlist? → Can access as a guest
The Flow
1. AUTHENTICATION
User signs in with wallet → We know WHO they are
2. AUTHORIZATION
Check NFT ownership → We know WHAT they can access
Check role (owner/admin/member) → We know WHAT they can do
Common Mistake
// WRONG: Only checking authentication
if (user.isSignedIn) {
showWorkspaceContent(); // Anyone signed in can see everything!
}
// RIGHT: Check both
if (user.isSignedIn && user.hasNFTAccess && user.role === 'admin') {
showAdminPanel();
}
3. Verifying NFT Ownership
The core of token gating is checking if a user owns a specific NFT. Here’s how to do it securely.
Don’t: Check on the client
// INSECURE
const nfts = await connection.getParsedTokenAccountsByOwner(wallet);
const hasNFT = nfts.some((nft) => nft.collection === targetCollection);
Do: Check on the server via a trusted API
// SECURE: Edge Function
const response = await fetch(`https://api.vybenetwork.xyz/v4/wallets/${wallet}/nft-balance`, {
headers: { 'X-API-Key': process.env.VYBE_API_KEY }
});
const nfts = await response.json();
const hasNFT = nfts.some((nft) => nft.collectionAddress === targetCollection);
Why Use an NFT API?
You could query the Solana blockchain directly, but:
- It’s slow (multiple RPC calls per wallet)
- It’s complex (parsing token metadata)
- It’s expensive (RPC rate limits)
APIs like Vybe Network, Helius, or SimpleHash index the blockchain and provide fast, reliable lookups.
Check ALL User Wallets
Users often have multiple wallets. If they own the NFT in Wallet B but signed in with Wallet A, they should still get access.
// Get all wallets linked to this user
const wallets = await getUserWallets(userId);
// Check each wallet for the NFT
for (const wallet of wallets) {
const hasNFT = await checkNFT(wallet.address, collection);
if (hasNFT) {
return true; // Found it!
}
}
return false; // No wallet has the NFT
4. Protecting Your APIs
Your Edge Functions are the gatekeepers. If they’re not secured, nothing else matters.
Always Require Authentication (for sensitive endpoints)
// Edge Function
const authHeader = req.headers.get('authorization');
if (!authHeader?.startsWith('Bearer ')) {
return new Response(JSON.stringify({ error: 'Unauthorized' }), {
status: 401 // Not 200!
});
}
const { user, error } = await supabase.auth.getUser(token);
if (error || !user) {
return new Response(JSON.stringify({ error: 'Invalid token' }), {
status: 401
});
}
// Now we know who's calling
Use Proper HTTP Status Codes
| Code | Meaning | When to Use |
|---|---|---|
| 200 | OK | Request succeeded |
| 400 | Bad Request | Invalid input |
| 401 | Unauthorized | Not logged in / bad token |
| 403 | Forbidden | Logged in but not allowed |
| 429 | Too Many Requests | Rate limited |
| 500 | Server Error | Something broke |
Why Status Codes Matter
// BAD: Always returns 200
return new Response(
JSON.stringify({
success: false,
error: 'Not authorized'
})
);
// Client might not check the body, assumes success
// GOOD: Returns appropriate status
return new Response(JSON.stringify({ error: 'Not authorized' }), {
status: 403
});
// Client libraries automatically handle this as an error
5. Database Security with RLS
Row Level Security (RLS) is your database’s immune system. It ensures users can only access data they’re allowed to see.
What is RLS?
RLS adds automatic filters to every database query based on who’s asking.
-- Without RLS:
SELECT * FROM workspaces;
-- Returns ALL workspaces (bad!)
-- With RLS:
SELECT * FROM workspaces;
-- Returns only workspaces the current user can access (good!)
Basic RLS Pattern
-- Enable RLS on the table
ALTER TABLE workspaces ENABLE ROW LEVEL SECURITY;
-- Create a policy
CREATE POLICY "Users can view their workspaces"
ON workspaces
FOR SELECT
USING (
id IN (
SELECT workspace_id FROM workspace_members
WHERE user_id = auth.uid()
)
);
Common RLS Patterns We Use
1. Users can only see their own data:
CREATE POLICY "Users view own wallets"
ON user_wallets FOR SELECT
USING (user_id = get_current_user_id());
2. Admins can manage workspace data:
CREATE POLICY "Admins manage gating rules"
ON workspace_gating_rules FOR ALL
USING (
workspace_id IN (
SELECT workspace_id FROM workspace_admins
WHERE wallet_address IN (
SELECT wallet_address FROM user_wallets
WHERE user_id = get_current_user_id()
)
)
);
3. No client access (server only):
-- By not creating any policies, authenticated users are denied
-- Only service_role (Edge Functions) can access
ALTER TABLE sensitive_data ENABLE ROW LEVEL SECURITY;
-- No policies = no access for regular users
RLS Gotcha: NULL Returns
When RLS blocks access, queries return NULL or empty results, not errors:
// User tries to access workspace they don't own
const { data } = await supabase.from('workspaces').select('*').eq('id', 'someone-elses-workspace');
// data is null or [] - not an error!
// Your code must handle this
if (!data) {
throw new Error('Workspace not found or access denied');
}
6. Caching Safely
NFT checks are slow and cost money (API calls). Caching speeds things up, but introduces security risks.
Why Cache?
Without caching:
- User visits workspace → API call
- User refreshes page → Another API call
- User navigates → More API calls
- 100 users × 10 page views = 1000 API calls per hour
With caching:
- User visits workspace → API call → Cache result
- Next 10 visits → Use cached result
- 100 users = 100 API calls per hour (10x reduction)
Cache Risks
1. Stale Data User sells their NFT, but cache says they still have it.
Solution: Short TTL (time to live). We use 1 hour.
const CACHE_TTL = 60 * 60 * 1000; // 1 hour
const expiresAt = new Date(Date.now() + CACHE_TTL);
2. Cache Poisoning Attacker tricks the system into caching false data.
Solution: Validate before caching.
// DANGEROUS: Cache any wallet
await cacheResult(userId, walletAddress, hasNFT);
// SAFE: Verify wallet belongs to user first
const userWallets = await getUserWallets(userId);
if (!userWallets.includes(walletAddress)) {
console.warn('Attempted cache poisoning');
return;
}
await cacheResult(userId, walletAddress, hasNFT);
3. Cache Invalidation User removes a wallet, but cache still references it.
Solution: Trigger to clear cache when wallets change.
CREATE FUNCTION invalidate_cache_on_wallet_removal()
RETURNS TRIGGER AS $$
BEGIN
DELETE FROM access_cache
WHERE wallet_address = OLD.wallet_address
AND user_id = OLD.user_id;
RETURN OLD;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER invalidate_cache
AFTER DELETE ON user_wallets
EXECUTE FUNCTION invalidate_cache_on_wallet_removal();
Our Caching Strategy
1. User requests access
2. Check cache for valid (non-expired) entry
3. If cached: Return immediately
4. If not cached: Call Vybe API
5. Verify wallet ownership
6. Store result with 1-hour TTL
7. Return result
7. Input Validation
Never assume input is valid. Always validate.
Validate Wallet Addresses
// BAD: Trust user input
const wallet = req.body.wallet_address;
await checkNFT(wallet, collection);
// GOOD: Validate first
function isValidSolanaAddress(address) {
if (!address || typeof address !== 'string') return false;
if (address.length < 32 || address.length > 44) return false;
if (!/^[1-9A-HJ-NP-Za-km-z]+$/.test(address)) return false;
try {
new PublicKey(address); // Actually parse it
return true;
} catch {
return false;
}
}
const wallet = req.body.wallet_address;
if (!isValidSolanaAddress(wallet)) {
return errorResponse(400, 'Invalid wallet address');
}
Why the Regex?
Solana uses Base58 encoding, which excludes confusing characters:
- No
0(zero) - looks likeO - No
O(letter O) - looks like0 - No
I(letter I) - looks likel - No
l(letter l) - looks like1
Validate on Both Sides
Client-side validation → Better user experience
Server-side validation → Actual security
You need BOTH.
Client validation gives instant feedback (“Invalid address format”). Server validation catches attackers who bypass the client.
8. Rate Limiting
Without rate limiting, attackers can:
- Drain your API budget
- Overload your servers
- Enumerate all wallets in your database
- Brute force access
Simple In-Memory Rate Limiting
const rateLimits = new Map();
function checkRateLimit(identifier, maxRequests = 100, windowMs = 60000) {
const now = Date.now();
const record = rateLimits.get(identifier);
if (!record || now > record.resetAt) {
rateLimits.set(identifier, { count: 1, resetAt: now + windowMs });
return true; // Allowed
}
if (record.count >= maxRequests) {
return false; // Blocked
}
record.count++;
return true; // Allowed
}
// In your Edge Function
const userIp = req.headers.get('x-forwarded-for');
if (!checkRateLimit(userIp)) {
return new Response('Rate limit exceeded', { status: 429 });
}
Rate Limit by What?
| Identifier | Use Case |
|---|---|
| IP Address | Unauthenticated endpoints |
| User ID | Authenticated endpoints |
| Wallet Address | Wallet-specific operations |
Our Rate Limits
| Endpoint | Limit | Window |
|---|---|---|
| check-wallet | 30 requests | 1 minute |
| verify-nft-ownership | 50 requests | 15 minutes |
| verify-workspace-access | 100 requests | 15 minutes |
9. CORS: Who Can Call Your APIs
CORS (Cross-Origin Resource Sharing) controls which websites can call your APIs from the browser.
The Problem with Wildcard CORS
// DANGEROUS
const corsHeaders = {
'Access-Control-Allow-Origin': '*' // Any website can call this!
};
With *, any website can embed your API calls. Attackers can:
- Create phishing sites that use your auth
- Drain your API quota from their domain
- Harvest data about your users
The Solution: Whitelist Origins
const ALLOWED_ORIGINS = [
'https://yourapp.com',
'https://staging.yourapp.com',
'http://localhost:5173' // Development
];
function getCorsHeaders(request) {
const origin = request.headers.get('origin');
const allowedOrigin = ALLOWED_ORIGINS.includes(origin) ? origin : ALLOWED_ORIGINS[0];
return {
'Access-Control-Allow-Origin': allowedOrigin,
'Access-Control-Allow-Headers': 'authorization, content-type',
'Access-Control-Allow-Methods': 'POST, OPTIONS'
};
}
Don’t Forget OPTIONS
Browsers send a “preflight” OPTIONS request before the actual request. Handle it:
if (req.method === 'OPTIONS') {
return new Response('ok', { headers: corsHeaders });
}
10. Security Headers
HTTP headers tell browsers how to handle your content securely.
Content Security Policy (CSP)
Prevents XSS attacks by controlling what can run on your page.
response.headers.set(
'Content-Security-Policy',
[
"default-src 'self'",
"script-src 'self'",
"style-src 'self' 'unsafe-inline'",
"connect-src 'self' https://*.supabase.co https://api.vybenetwork.xyz",
"frame-ancestors 'none'"
].join('; ')
);
Other Important Headers
// Prevent clickjacking
headers.set('X-Frame-Options', 'DENY');
// Prevent MIME sniffing
headers.set('X-Content-Type-Options', 'nosniff');
// Control referrer info
headers.set('Referrer-Policy', 'strict-origin-when-cross-origin');
In SvelteKit
// src/hooks.server.ts
export const handle = async ({ event, resolve }) => {
const response = await resolve(event);
response.headers.set('X-Frame-Options', 'DENY');
response.headers.set('X-Content-Type-Options', 'nosniff');
// ... more headers
return response;
};
11. Signature Verification
When users link wallets, they sign a message to prove ownership. Here’s how to verify it securely.
The Flow
1. App generates a message: "Link wallet ABC123 to account"
2. User signs with their private key
3. App verifies signature matches the wallet's public key
4. If valid, wallet is linked
Verification Code
import nacl from 'tweetnacl';
function verifySignature(message, signature, publicKey) {
return nacl.sign.detached.verify(new TextEncoder().encode(message), signature, publicKey);
}
Preventing Replay Attacks
Without protection, an attacker could capture a signature and replay it later.
Solution: Use nonces (one-time codes)
// 1. Generate unique nonce
const nonce = crypto.randomUUID();
const message = `Link wallet ${address}\nNonce: ${nonce}`;
// 2. Store nonce in database with expiry
await db.insert({
nonce,
userId,
expiresAt: Date.now() + 5 * 60 * 1000 // 5 minutes
});
// 3. After signature verified, check nonce
const nonceRecord = await db.find({ nonce, userId });
if (!nonceRecord || nonceRecord.expiresAt < Date.now()) {
throw new Error('Invalid or expired nonce');
}
// 4. Delete nonce (can't be reused)
await db.delete({ nonce });
12. Common Pitfalls
Pitfall 1: Owners/Admins Bypass NFT Check
Wrong: “They’re the owner, so they always have access.”
Right: Owners must also hold the NFT. If they sell it, they lose access.
// WRONG
if (isOwner(user)) {
grantAccess(); // Even if they sold the NFT!
}
// RIGHT
if (hasNFT(user)) {
const role = isOwner(user) ? 'owner' : 'member';
grantAccess(role);
}
// No NFT = no access, even for owners
Pitfall 2: Race Conditions
Problem: Two requests arrive simultaneously, both think they’re creating the “first” wallet.
Solution: Database constraints and transactions.
-- Only one primary wallet per user
CREATE TRIGGER enforce_single_primary
BEFORE INSERT OR UPDATE ON user_wallets
FOR EACH ROW
WHEN (NEW.is_primary = TRUE)
EXECUTE FUNCTION unset_other_primaries();
Pitfall 3: Information Leakage
Wrong: Detailed error messages.
// DANGEROUS
return { error: `User ${userId} not found in table users` };
// Reveals database structure!
Right: Generic messages.
// SAFE
return { error: 'Authentication failed' };
// Log details server-side, return generic message
Pitfall 4: Forgetting Cleanup
Caches grow forever without cleanup. Schedule regular cleanups:
-- Delete expired entries daily
CREATE FUNCTION cleanup_expired()
RETURNS void AS $$
DELETE FROM access_cache WHERE expires_at < NOW();
DELETE FROM nonces WHERE expires_at < NOW();
$$ LANGUAGE sql;
-- Schedule with pg_cron
SELECT cron.schedule('cleanup', '0 0 * * *', 'SELECT cleanup_expired()');
Security Checklist
Before launching your token-gated app, verify:
Authentication
- Wallet signatures verified server-side
- JWT tokens validated on every request
- Nonces prevent signature replay
Authorization
- NFT ownership checked server-side
- All user wallets checked (not just the sign-in wallet)
- Roles enforced (owner/admin/member)
- Owners/admins still need NFT
API Security
- CORS restricted to your domains
- Rate limiting implemented
- Input validation on all endpoints
- Proper HTTP status codes
Database Security
- RLS enabled on all tables
- Policies for each table/operation
- Immutable fields protected by triggers
- Cache entries validated before storage
General
- Security headers configured
- Secrets in environment variables
- Error messages don’t leak info
- Cleanup jobs for expired data
Conclusion
Building a secure token-gated application requires thinking about security at every layer:
- Client: Validate input, but never trust it
- Transport: Use HTTPS, set CORS properly
- Server: Verify everything, rate limit, use proper status codes
- Database: RLS policies, constraints, triggers
The most important principle: Never trust the client. Always verify on the server.
Security isn’t a feature you add at the end—it’s a mindset you apply from the start. Every function you write, ask yourself: “How could this be abused?”
Resources
This guide was written based on real security issues identified and fixed in the TreehouseHQ codebase. All code examples are simplified for clarity—always adapt to your specific needs.
Related Articles
- Reverse Engineering a Solana Program: A Beginner's Guide
How to decode an unknown staking program using only on-chain data, transaction history, and patience. A step-by-step walkthrough of reading raw bytes off the blockchain.
- Building Solana Wallet Auth with Svelte 5 and Supabase
A practical guide to building web3 authentication with multi-wallet support, covering Svelte 5 runes, Supabase Web3 auth, and Row Level Security.
- Understanding Solana NFTs: A Complete Guide
Everything you see when viewing an NFT on Solana Explorer, how each piece was created, and what can be changed after minting.