Python 3 and a terminal. No Telegram bot, no API key, no internet. macOS
and Linux ship with Python; on Windows install it from python.org with
“Add python.exe to PATH” ticked. Check with
python3 --version. Everything below uses modules that come with Python.
The bot token in these scripts is a made-up string — never put a real one in a file you might share, which is a point step 5 makes rather forcefully. And before building anything real, confirm the exact signing procedure against Telegram’s current Mini Apps documentation: the shape below is what these checks look like, and a security check has to match the specification exactly rather than approximately.
Understanding the TON Ecosystem
TON is not just a cryptocurrency — it is a platform with a growing ecosystem of applications, tokens, services, and infrastructure. What makes TON unique is its deep integration with Telegram, giving it instant access to hundreds of millions of users.
Jettons: TON's Token Standard
Jettons are TON's equivalent of ERC-20 tokens on Ethereum. They follow the TEP-74 standard and allow anyone to create custom tokens on the TON blockchain.
- How they work: Each Jetton has a master contract (defines supply, metadata) and wallet contracts (one per holder). This is different from Ethereum where one contract tracks all balances
- Popular Jettons: USDT on TON, NOT (Notcoin), DOGS, STON, DeDust tokens
- Security note: Anyone can create a Jetton with any name. A token called "USDT" in your wallet may not be the real USDT. Always verify the contract address
Scammers regularly airdrop fake Jettons to random wallets. These tokens often have names like "1000 USDT Claim" or "Free TON Reward." Interacting with them (trying to swap or claim) can drain your wallet. Ignore unsolicited tokens.
NFTs on TON
TON supports NFTs following the TEP-62 standard. Key features:
- Telegram Usernames: Through Fragment, Telegram usernames (@name) are NFTs on TON that can be bought, sold, and transferred
- Anonymous Numbers: Virtual phone numbers for Telegram, also NFTs on TON
- Art and Collectibles: Traditional NFT art collections exist on platforms like Getgems
- SBT (Soulbound Tokens): Non-transferable tokens used for identity verification, achievements, and memberships
TON DNS
TON DNS maps human-readable names (like myname.ton) to TON addresses, websites, or other resources.
It works at the protocol level, not as an external service.
- .ton domains: Resolve to wallet addresses, making it easy to send Toncoin to "alice.ton" instead of a long address string
- Website hosting: .ton domains can point to TON Sites (decentralized web pages)
- Subdomains: Domain owners can create subdomains for organization
TON Proxy and TON Sites
TON includes a built-in proxy network and decentralized website hosting:
- TON Proxy: A network layer that routes traffic through TON nodes, providing censorship resistance. Similar in concept to Tor but integrated into the blockchain
- TON Sites: Websites hosted on the TON network, accessible through TON Proxy. These sites cannot be taken down by traditional means
- TON Storage: Decentralized file storage using a BitTorrent-like protocol built into TON. Files are stored across network participants
Telegram Mini Apps
Mini Apps are web applications that run inside Telegram. They are the primary way users interact with the TON ecosystem without leaving the Telegram app:
- What they are: HTML/JS web apps rendered inside Telegram's built-in browser
- TON Connect: A protocol that lets Mini Apps request wallet connections and transaction approvals
- Popular examples: Notcoin (tap-to-earn game), Hamster Kombat, trading bots, DeFi interfaces
- Revenue: Mini Apps can monetize through Telegram Stars and in-app Toncoin payments
Mini Apps can request wallet connection and transaction approval. A malicious Mini App could trick you into signing a transaction that drains your wallet. Only use Mini Apps from trusted sources and always review what you are approving before confirming.
Major TON Projects
Now Check Who a Mini App Is Really Talking To, in Five Steps
The TON ecosystem’s biggest advantage is that its applications open inside Telegram, where hundreds of millions of people already are. That convenience creates one specific security question: when a Mini App tells its server “this is user 778899”, why should the server believe it? In the next twenty-five minutes you will implement the answer, forge it four ways, and find the one secret that holds the whole scheme together. Every line of output below came from running these files.
Go: open a terminal in a folder you can write to, e.g.
cd ~/Desktop (Windows: cd %USERPROFILE%\Desktop).
Do: save this as miniapp.py. It prints nothing; it is the
toolbox. The clock is passed in as an argument rather than read from your system, so every
result below is reproducible.
import hmac, hashlib
from urllib.parse import parse_qsl
def data_check_string(fields: dict) -> str:
"""Every field except the hash, sorted by name, one per line."""
return "\n".join(f"{k}={v}" for k, v in sorted(fields.items()) if k != "hash")
def sign(fields: dict, bot_token: str) -> str:
secret = hmac.new(b"WebAppData", bot_token.encode(), hashlib.sha256).digest()
return hmac.new(secret, data_check_string(fields).encode(), hashlib.sha256).hexdigest()
def verify(init_data: str, bot_token: str, now: int, max_age: int = 86_400):
fields = dict(parse_qsl(init_data))
if "hash" not in fields:
return "REJECTED: no hash at all"
if not hmac.compare_digest(sign(fields, bot_token), fields["hash"]):
return "REJECTED: hash does not match"
age = now - int(fields.get("auth_date", 0))
if age > max_age:
return f"REJECTED: {age // 3600} hours old"
return f"ACCEPTED: user {fields.get('id')}"
You should see: nothing. python3 miniapp.py should return to
the prompt in silence.
If not: ImportError: cannot import name 'parse_qsl' means the
import line was mistyped — it comes from urllib.parse. Note
hmac.compare_digest rather than ==: comparing secrets with
== leaks information through how long the comparison takes.
Go: same folder.
Do: save this as login.py and run
python3 login.py.
from urllib.parse import urlencode
from miniapp import sign, verify
BOT_TOKEN = "1234567:practice-token-not-a-real-one"
NOW = 1_700_000_000 # a fixed clock, so your output matches this page
fields = {"id": "778899", "username": "jane", "auth_date": str(NOW - 60)}
fields["hash"] = sign(fields, BOT_TOKEN)
init_data = urlencode(fields)
print("what Telegram hands your page:")
print(" ", init_data)
print()
print("your server checks it:", verify(init_data, BOT_TOKEN, NOW))
You should see: an ordinary-looking query string, and an accepted user:
what Telegram hands your page:
id=778899&username=jane&auth_date=1699999940&hash=5c8caf1a7b14800df6559038ea0ab48992a5df21aee6298f073aed3e6759fbea
your server checks it: ACCEPTED: user 778899
Everything except that final hash is plain, readable text the browser can
edit. The hash is the only thing separating “Telegram says this is Jane” from
“the page claims to be Jane”.
If not: ModuleNotFoundError: No module named 'miniapp' means
the files are not in the same folder. A different hash means a field value differs; the
signature covers every one of them.
Go: same folder. Editing a query string is the easiest attack there is.
Do: save this as forge.py and run it. Two attempts: change
the user id, and reuse a correctly signed sign-in from days ago.
from urllib.parse import urlencode
from miniapp import sign, verify
BOT_TOKEN = "1234567:practice-token-not-a-real-one"
NOW = 1_700_000_000
fields = {"id": "778899", "username": "jane", "auth_date": str(NOW - 60)}
fields["hash"] = sign(fields, BOT_TOKEN)
genuine = urlencode(fields)
forged = urlencode({**fields, "id": "1"}) # claim to be a different user
stale = dict(fields, auth_date=str(NOW - 400_000))
stale["hash"] = sign(stale, BOT_TOKEN) # correctly signed, just old
print("genuine :", verify(genuine, BOT_TOKEN, NOW))
print("user id changed :", verify(forged, BOT_TOKEN, NOW))
print("old but well signed:", verify(urlencode(stale), BOT_TOKEN, NOW))
You should see: both attacks refused, for two different reasons:
genuine : ACCEPTED: user 778899
user id changed : REJECTED: hash does not match
old but well signed: REJECTED: 111 hours old
The third line is the one people leave out. That sign-in is perfectly signed — it
was genuine, once. Without the age check, anyone who ever captured a valid
initData (from a browser history, a log file, a shared screenshot) could replay
it forever. A signature proves authenticity, never freshness.
If not: if the second line is accepted, the forged copy is being re-signed
— note that {**fields, "id": "1"} deliberately keeps the old hash.
Go: same folder. This is the most common Mini App vulnerability, and it is not subtle.
Do: save this as naive.py and run it.
from urllib.parse import parse_qsl, urlencode
from miniapp import verify
BOT_TOKEN = "1234567:practice-token-not-a-real-one"
NOW = 1_700_000_000
def naive_server(init_data):
"""Reads the user id and believes it. This is the mistake."""
return dict(parse_qsl(init_data)).get("id")
attack = urlencode({"id": "1", "username": "admin", "auth_date": str(NOW)})
print("attacker sends :", attack)
print("naive server sees user:", naive_server(attack))
print("checked server says :", verify(attack, BOT_TOKEN, NOW))
You should see: the same request believed by one server and refused by the other:
attacker sends : id=1&username=admin&auth_date=1700000000
naive server sees user: 1
checked server says : REJECTED: no hash at all
There is no hash at all in that request, and the naive server never looked for one. This is what “the app opens inside Telegram” is worth on its own: nothing. The attacker does not have to open it inside Telegram — they send the request directly. As a user, the practical consequence is that a Mini App’s security is entirely a property of code you cannot inspect, which is a reason to connect a wallet holding pocket money rather than savings.
If not: if the naive server prints None, the field name in
the attack does not match what it reads — both must say id.
Go: same folder. The check works. What is it trusting?
Do: save this as leaked.py and run it. The attacker has the
bot token — from a public repository, a screenshot in a support chat, or an error log.
from urllib.parse import urlencode
from miniapp import sign, verify
REAL_TOKEN = "1234567:practice-token-not-a-real-one"
NOW = 1_700_000_000
stolen = REAL_TOKEN
forged = {"id": "1", "username": "admin", "auth_date": str(NOW)}
forged["hash"] = sign(forged, stolen)
print("forged with the stolen token:", verify(urlencode(forged), REAL_TOKEN, NOW))
print()
print("the same forgery once the token is rotated:")
print(" ", verify(urlencode(forged), "1234567:the-new-token-after-rotation", NOW))
You should see: a perfect forgery, and the one thing that stops it:
forged with the stolen token: ACCEPTED: user 1
the same forgery once the token is rotated:
REJECTED: hash does not match
The bot token is not merely a way to send messages — it is the key that authenticates every user of the Mini App. Whoever holds it can sign in as anybody, including whichever account the app treats as an administrator. So it belongs in an environment variable on the server and nowhere else: never in front-end code, never in a repository, never in a screenshot, and never in a log line. And if it has ever been exposed, rotating it is not optional — as the second line shows, rotation is what actually invalidates a forgery.
If not: if the second line is accepted, the same token string was passed twice — the rotated token must be a genuinely different string.
Without scrolling up: a Mini App asks you to connect your wallet and sign a message to “verify ownership”. Given what steps 4 and 5 showed, what does opening it inside Telegram guarantee about the app, and what should you actually check before signing? Answer: opening it inside Telegram guarantees nothing about the app — Telegram provides the app with signed data about you, but does not vet what the app does with it, and an attacker can bypass the Telegram side altogether by talking to the server directly. Before signing, read what the signature actually authorises: a plain login proof is harmless, while a transaction or a token approval is not, and a wallet showing an unlimited approval for a “verification” is the moment to stop. Connect a wallet holding only what you can afford to lose in that app.
Now do it without the page: extend verify so it also refuses
initData it has seen before — keep a set of hashes already accepted —
then confirm that replaying a fresh, correctly signed sign-in twice succeeds once and fails
the second time. You have just built the difference between “this was genuine” and
“this is a new sign-in”, which is the same distinction step 3 exposed with the
age check.
Summary
- Jettons are TON's token standard — always verify contract addresses before trusting token names
- NFTs on TON include Telegram usernames, phone numbers, and traditional collectibles
- TON DNS provides human-readable .ton addresses at the protocol level
- TON Proxy and Sites enable censorship-resistant web hosting
- Telegram Mini Apps are the primary user interface for the TON ecosystem
- Always review transaction details when connecting wallets to Mini Apps or dApps
You can navigate the landscape of TON applications and services while understanding the security implications of each.