TL;DR. TonProof extends TON Connect 2.0 into a full Sign-in system: instead of email/password the user signs a server-issued nonce, and the server issues a session token. Build time 2-3 hours: frontend (TonConnectUI with tonProof in connectRequestParameters), server (nonce generation + signature verification via @tonconnect/sdk). The key parts — correct nonce pipeline (generate → sign → verify → blacklist), httpOnly cookies, and graceful handling when wallets don’t support TonProof. Production-ready code below.
Why TonProof exists
Basic TON Connect 2.0 flow:
- User scans QR / clicks the dApp.
- Wallet opens, user confirms connect.
- Wallet returns address + public_key + state_init.
The problem: this can be forged. Anyone can “connect” a wallet to your dApp claiming someone else’s address — via a modified TON Connect client that sends an unverified address. Without a signature you don’t know who’s actually connected.
TonProof fixes this: the dApp asks the wallet to sign a message containing the server’s nonce + the dApp domain + a timestamp. The signature is made with the wallet’s private key — only the real owner has it. On the server you verify the signature against the public_key — now you have cryptographic proof.
Architecture
┌──────────────┐ ┌────────────────┐ ┌──────────────┐
│ Frontend │ 1. │ Your Backend │ │ Wallet │
│ (Mini App │ ---> │ (Node/Go/Rust)│ │ (Tonkeeper) │
│ or Web) │ getNonce │ │ │
└──────┬───────┘ <--- └────────────────┘ └──────┬───────┘
│ nonce │
│ 2. connect + sign(payload(nonce)) │
│ ------------------------------------------------> │
│ │
│ <-- signature, address, state_init ------- │
│ │
│ 3. POST /auth/verify { nonce, signature, ... }
│ --> Backend verifies sig, issues JWT
│ │
│ 4. Cookies set; subsequent requests are authed.
Three backend endpoints:
-
GET /auth/nonce— returns a fresh nonce -
POST /auth/verify— accepts the connect result, verifies, issues session JWT -
GET /auth/me— checks JWT cookie, returns user data
Backend: nonce, verify, session
Base Node.js + Express implementation:
import express from 'express';
import crypto from 'crypto';
import { sign, verify as jwtVerify } from 'jsonwebtoken';
import { tonProofVerifySignature } from './tonproof-verify';
const app = express();
app.use(express.json());
const NONCE_TTL_MS = 5 * 60 * 1000; // 5 min
const nonces = new Map(); // nonce → expires_at
// 1) Nonce endpoint
app.get('/auth/nonce', (req, res) => {
const nonce = crypto.randomBytes(32).toString('hex');
nonces.set(nonce, Date.now() + NONCE_TTL_MS);
res.json({ nonce });
});
// Cleanup expired nonces every minute
setInterval(() => {
const now = Date.now();
for (const [n, exp] of nonces) if (exp < now) nonces.delete(n);
}, 60_000);
// 2) Verify endpoint
app.post('/auth/verify', async (req, res) => {
const { proof, account } = req.body;
/* proof: {
timestamp: number,
domain: { lengthBytes, value },
signature: string (base64),
payload: string // == nonce
}
account: {
address: string,
publicKey: string,
chain: '-239' | '-3'
}
*/
const nonce = proof.payload;
if (!nonces.has(nonce)) {
return res.status(401).json({ error: 'Nonce expired or unknown' });
}
nonces.delete(nonce); // burn immediately — single-use
// Domain match
if (proof.domain.value !== process.env.TON_PROOF_DOMAIN) {
return res.status(401).json({ error: 'Domain mismatch' });
}
// Timestamp not too old (5 min) and not in the future (clock skew)
const ageMs = Date.now() - proof.timestamp * 1000;
if (ageMs > NONCE_TTL_MS || ageMs < -60_000) {
return res.status(401).json({ error: 'Stale or future timestamp' });
}
// Crypto verification
const ok = await tonProofVerifySignature({
address: account.address,
publicKey: account.publicKey,
proof,
});
if (!ok) return res.status(401).json({ error: 'Bad signature' });
// Issue JWT
const token = sign(
{ sub: account.address, pk: account.publicKey },
process.env.JWT_SECRET!,
{ expiresIn: '24h' },
);
res
.cookie('session', token, {
httpOnly: true,
secure: true,
sameSite: 'lax',
maxAge: 24 * 60 * 60 * 1000,
})
.json({ ok: true });
});
// 3) Me endpoint
app.get('/auth/me', (req, res) => {
const token = req.cookies?.session;
if (!token) return res.status(401).end();
try {
const payload = jwtVerify(token, process.env.JWT_SECRET!);
res.json(payload);
} catch {
res.status(401).end();
}
});
app.listen(3000);
Backend: signature verification
The most critical part — tonProofVerifySignature. As of mid-2026 the recommended path is @ton/ton + @tonconnect/sdk-server:
import { Address } from '@ton/core';
import nacl from 'tweetnacl';
import crypto from 'node:crypto';
import { Buffer } from 'node:buffer';
const TON_PROOF_PREFIX = 'ton-proof-item-v2/';
const TON_CONNECT_PREFIX = 'ton-connect';
export async function tonProofVerifySignature({
address, publicKey, proof,
}: {
address: string;
publicKey: string;
proof: {
timestamp: number;
domain: { lengthBytes: number; value: string };
signature: string;
payload: string;
};
}): Promise {
const addr = Address.parse(address);
// Construct the message that the wallet signed:
// ton-proof-item-v2 + addr(workchain + hash) + len(domain) + domain + timestamp + payload
const message = Buffer.concat([
Buffer.from(TON_PROOF_PREFIX, 'utf8'),
addressToBuffer(addr),
intToLEBuffer(proof.domain.lengthBytes, 4),
Buffer.from(proof.domain.value, 'utf8'),
intToLEBuffer(proof.timestamp, 8),
Buffer.from(proof.payload, 'utf8'),
]);
// The hash that is signed:
// hash = sha256(0xffff + 'ton-connect' + sha256(message))
const inner = crypto.createHash('sha256').update(message).digest();
const fullMessage = Buffer.concat([
Buffer.from([0xff, 0xff]),
Buffer.from(TON_CONNECT_PREFIX, 'utf8'),
inner,
]);
const hash = crypto.createHash('sha256').update(fullMessage).digest();
// Ed25519 verify
const sigBuf = Buffer.from(proof.signature, 'base64');
const pubBuf = Buffer.from(publicKey, 'hex');
return nacl.sign.detached.verify(hash, sigBuf, pubBuf);
}
function addressToBuffer(addr: Address): Buffer {
const buf = Buffer.alloc(36);
buf.writeInt32BE(addr.workChain, 0);
Buffer.from(addr.hash).copy(buf, 4);
return buf;
}
function intToLEBuffer(n: number, bytes: number): Buffer {
const buf = Buffer.alloc(bytes);
buf.writeIntLE(n, 0, bytes);
return buf;
}
Important bits:
- publicKey arrives from the wallet in hex; convert to Buffer.
-
address — bounceable string from the wallet; parse via
@ton/core. - TonProof v2 signature schema (prefix, domain, payload, timestamp, address hash).
- Ed25519 — TON’s standard signature algorithm.
In production use a ready library (e.g. tonkeeper’s tonProof-checker) rather than reimplementing the crypto — edge cases are pre-handled.
Frontend: TonConnect UI + tonProof
On the frontend use @tonconnect/ui-react:
import { TonConnectUIProvider, TonConnectButton, useTonConnectUI, useTonAddress } from '@tonconnect/ui-react';
import { useEffect } from 'react';
function App() {
return (
);
}
function AuthScreen() {
const [tonConnectUI] = useTonConnectUI();
const address = useTonAddress();
// 1. Get nonce when component mounts
useEffect(() => {
fetchAndRefreshTonProof();
}, []);
// 2. Listen for successful wallet connections
useEffect(() => {
return tonConnectUI.onStatusChange(async (wallet) => {
if (!wallet) return; // disconnected
if (wallet.connectItems?.tonProof && 'proof' in wallet.connectItems.tonProof) {
await fetch('/auth/verify', {
method: 'POST',
credentials: 'include',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
proof: wallet.connectItems.tonProof.proof,
account: wallet.account,
}),
});
}
});
}, [tonConnectUI]);
async function fetchAndRefreshTonProof() {
const res = await fetch('/auth/nonce');
const { nonce } = await res.json();
tonConnectUI.setConnectRequestParameters({
state: 'ready',
value: { tonProof: nonce },
});
}
return (
{address && Connected: {address}
}
);
}
Key steps:
- Get a nonce on page load or on button init.
- Pass it to
tonConnectUI.setConnectRequestParameters({ value: { tonProof: nonce } })before the user clicks connect. - Listen to
onStatusChange. Ifwallet.connectItems.tonProofhasproof— POST to server for verify. - After successful verify the server sets a cookie;
/auth/mestarts returning user data.
TonConnect manifest
File at https://yourdomain.com/tonconnect-manifest.json:
{
"url": "https://yourdomain.com",
"name": "Your dApp Name",
"iconUrl": "https://yourdomain.com/icon-180.png",
"termsOfUseUrl": "https://yourdomain.com/terms",
"privacyPolicyUrl": "https://yourdomain.com/privacy"
}
Icon must be 180×180 PNG. URL must match what’s recorded as proof.domain.value server-side.
Common pitfalls and fixes
”Bad signature” every time
Usually:
-
Wrong public key. Extracting publicKey from the wrong place in state_init. Fix: use
account.publicKeydirectly from the TonConnect result. -
Wrong domain. Server compares to a hardcoded value that doesn’t match. Fix: take domain from
proof.domain.valueand check against a whitelist (production + staging). - Wrong message construction. TonProof v2 has an exact format — verify byte-by-byte against a reference implementation.
Nonce expired right after connect
- TTL too short. 1 minute may not cover a slow user. 5 minutes is a reasonable compromise.
-
Servers in different TZ. Use
Date.now()everywhere (UTC ms epoch), notnew Date()parsing.
Replay attacks
-
Missing nonce blacklist. If you don’t remove the nonce from the Map after verify, an attacker can replay. Always
nonces.delete(nonce)on successful verify. -
JWT secret leaked. If JWT_SECRET leaks, the attacker mints their own tokens. Rotate every 90 days, store in a secret manager (Vault, AWS Secrets), not in
.env.
Wallet without TonProof support
Old xRocket versions, custom wallets — may return connect without proof. Fix:
- In
onStatusChange, check forwallet.connectItems?.tonProof. - If missing — fallback: either refuse login (strict dApps) or downgrade to plain connect without verification (UX-first apps).
Production checklist
- Nonce from cryptographically-secure random (not
Math.random()) - Nonce TTL 5 minutes + used-nonce blacklist
- Domain whitelist server-side (production + staging)
- httpOnly cookies + Secure + SameSite=Lax
- JWT_SECRET in secret manager, not in repo
- Rate limit on /auth/nonce and /auth/verify (10 rps/ip)
- Log failed verifies (attack detection)
- Tested on 4-5 wallets (Tonkeeper, MyTonWallet, Wallet, OKX, Tonhub)
- Fallback UX if the wallet doesn’t support TonProof
Session-token security
After issuing JWT:
- Don’t expose JWT in the frontend. httpOnly cookies are the only way; localStorage is XSS-vulnerable.
- Refresh tokens. For long sessions issue a separate refresh token (stored in DB, revocable).
- Logout. Clear the cookie + add the JWT-id to a revocation list (if JWT is stateless).
Bottom line
TonProof is a production-ready authentication method for TON dApps in 2026. 2-3 hours to implement, cryptographic-signature security, one-tap UX for the user. The key — correct nonce pipeline and server-side signature verification.
Use it as the main login for DeFi/NFT projects, as an optional second factor for traditional web. For payment acceptance — TonProof is step one; after login you can invoke sendTransaction through the same TON Connect.
Full payment guide — see How to accept TON in a Telegram bot.








