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'
Configuration
Secrets
| Name | Purpose |
|---|---|
| TELEGRAM_BOT_TOKEN | Bot credential from @BotFather |
| TELEGRAM_WEBHOOK_SECRET | Shared secret proving updates came from Telegram |
| NFT_COLLECTION_ID | Canonical on-chain certified collection ID |
| HELIUS_API_KEY | Required for reliable ownership data via the Helius DAS API |
| ADMIN_TELEGRAM_IDS | Telegram user IDs allowed to run admin bot commands |
| SESSION_SECRET | Signs the short-lived tokens behind /verify links |
| TELEGRAM_GROUP_ID | Optional. Can instead be confirmed conversationally via /setup |
Behaviour
| Var | Default | Purpose |
|---|---|---|
| ACCESS_GRACE_PERIOD_HOURS | 24 | Grace window after ownership is lost |
| MIGRATION_MODE | true | Protect pre-existing members from removal |
| RECHECK_INTERVAL_HOURS | 12 | How stale a check may get before re-running |
.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
- Create the Cloudflare resources:
wrangler d1 createandwrangler kv namespace create. - Validate the collection ID against the chain before going live.
- Set secrets with
wrangler secret put. - Apply migrations:
wrangler d1 migrations apply DB --remote. - Build the frontend and deploy:
pnpm run deploy. - 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:
| Permission | Why |
|---|---|
| Invite users via link | Mint the single-use invite links issued to verified users |
| Ban users | Remove 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:
| /setup | Check or configure the gated group |
| /adminstats | Membership 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.