◆ telegram-nft-gate

Overview

telegram-nft-gate verifies that a Telegram user controls a Solana wallet holding at least one NFT from a configured collection, then grants or removes access to a private Telegram group accordingly. It is self-hosted: each community deploys its own copy on its own Cloudflare account, rather than routing through a shared service.

The collection is configuration, not code. Nothing about a specific community or NFT collection is hard-coded into the application.

Architecture

A single Cloudflare Worker serves everything: the Telegram webhook, the verification API, and the React/Vite verification page, all same-origin. There is no separate frontend host and no Cloudflare Pages project involved.

Telegram
    |
    v
Cloudflare Worker
    |-- grammY webhook (bot commands)
    |-- verification API (/api/verify/*)
    |-- React/Vite frontend (/verify)
    |-- D1 (state, audit log)
    |-- KV (rate limits, runtime config)
    `-- Cron Trigger (scheduled rechecks)
         |
         v
    Helius DAS API

The backend is authoritative throughout. The frontend never asserts identity or ownership; every check is re-verified server-side against live on-chain data.

Resolving a collection ID

NFT_COLLECTION_ID must be the canonical on-chain certified collection ID, not a marketplace slug, collection name, symbol, creator address, candy machine address, or an individual mint.

Take a few known mints from the target collection and inspect their grouping via DAS getAsset. Every member of a certified collection points at the same collection address, and that address should itself resolve to a collection-type asset, not a member asset:

curl -s -X POST "https://mainnet.helius-rpc.com/?api-key=$HELIUS_API_KEY" \
  -H 'content-type: application/json' \
  -d '{"jsonrpc":"2.0","id":1,"method":"getAsset","params":{"id":"<MINT>"}}' \
  | jq '.result.grouping'
The project ships a validator that automates this check and refuses to guess or substitute a different ID if the configured one does not check out.

Configuration

Secrets

NamePurpose
TELEGRAM_BOT_TOKENBot credential from @BotFather
TELEGRAM_WEBHOOK_SECRETShared secret proving updates came from Telegram
NFT_COLLECTION_IDCanonical on-chain certified collection ID
HELIUS_API_KEYRequired for reliable ownership data via the Helius DAS API
ADMIN_TELEGRAM_IDSTelegram user IDs allowed to run admin bot commands
SESSION_SECRETSigns the short-lived tokens behind /verify links
TELEGRAM_GROUP_IDOptional. Can instead be confirmed conversationally via /setup

Behaviour

VarDefaultPurpose
ACCESS_GRACE_PERIOD_HOURS24Grace window after ownership is lost
MIGRATION_MODEtrueProtect pre-existing members from removal
RECHECK_INTERVAL_HOURS12How stale a check may get before re-running
These are operator config, not application defaults, and are set through the Cloudflare dashboard or .dev.vars locally, not committed to wrangler.jsonc. Wrangler treats that file as authoritative for a Worker's entire var set on every deploy, so a checked-in value there would silently overwrite whatever is configured in the dashboard.

Deployment

  1. Create the Cloudflare resources: wrangler d1 create and wrangler kv namespace create.
  2. Validate the collection ID against the chain before going live.
  3. Set secrets with wrangler secret put.
  4. Apply migrations: wrangler d1 migrations apply DB --remote.
  5. Build the frontend and deploy: pnpm run deploy.
  6. Register the Telegram webhook against the deployed URL.

Telegram setup

Create the bot with @BotFather, then disable group privacy mode so it receives membership updates.

Add the bot to the private group as an administrator with exactly two permissions:

PermissionWhy
Invite users via linkMint the single-use invite links issued to verified users
Ban usersRemove members after the grace period expires

TELEGRAM_GROUP_ID does not need to be known ahead of time: adding the bot to a group triggers a my_chat_member update, which the bot uses to DM every configured admin asking them to confirm it with /setup confirm.

Access lifecycle

unverified --verify + owns--> eligible
eligible   --lost NFT-------> grace --window expires--> revoked
grace      --regains NFT---> eligible
revoked    --re-verifies---> eligible

Ownership checks are tri-state: OWNED, NOT_OWNED, or INDETERMINATE. A DAS timeout, rate limit, or malformed response is always indeterminate, never treated as proof the NFT was sold. Indeterminate results change nothing, not even the recheck timestamp, so a transient outage cannot revoke anyone and the affected user is retried promptly rather than waiting a full interval.

Admin commands

Administration is bot commands, not a separate dashboard. Once a Telegram ID is listed in ADMIN_TELEGRAM_IDS, that admin gets a private, chat-scoped command menu:

/setupCheck or configure the gated group
/adminstatsMembership counts and migration progress
/adminusers <query>Search by ID, username, or wallet
/adminrecheck <id>Run a live ownership check on one user
/adminrevoke <id> [reason]Remove a user's access
/adminrestore <id>Restore access, only if the wallet still qualifies

There is deliberately no bypass command: restore only succeeds by re-proving ownership. Every admin action, including refused ones, is written to an audit log.

Migration mode

For a group that already has members before gating is switched on. While MIGRATION_MODE=true, pre-existing members are never removed automatically, no matter what their ownership check says, while new joiners are gated normally from day one. Turning it off begins normal enforcement for everyone.

Security

  • Wallet control is proven by an off-chain message signature. No transaction, no seed phrase, no private key, ever.
  • Challenges bind app identity, Telegram user ID, wallet address, a 256-bit nonce, and an expiry. They are single-use, burned by an atomic conditional update, so replay and substitution both fail.
  • One wallet maps to at most one Telegram account, enforced by a unique index.
  • Admin authorization is checked on every request, not just at login, so removing an ID revokes access immediately.