TON Connect 2.0 + TonProof: Sign-in with TON Tutorial (2026)
Build Sign-in with TON via TON Connect 2.0 and TonProof: server-side signature verification, nonce payload, typical mistakes, production-ready code.
- Author
- TON Adoption Team · research desk
- Published
Contents14sections
- Why TonProof exists
- Architecture
- Backend: nonce, verify, session
- Backend: signature verification
- Frontend: TonConnect UI + tonProof
- TonConnect manifest
- Common pitfalls and fixes
- ”Bad signature” every time
- Nonce expired right after connect
- Replay attacks
- Wallet without TonProof support
- Production checklist
- Session-token security
- Bottom line
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 noncePOST /auth/verify— accepts the connect result, verifies, issues session JWTGET /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<string, number>(); // 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<boolean> {
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 (
<TonConnectUIProvider manifestUrl="https://yourdomain.com/tonconnect-manifest.json">
<AuthScreen />
</TonConnectUIProvider>
);
}
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 (
<div>
<TonConnectButton />
{address && <p>Connected: {address}</p>}
</div>
);
}
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.
Frequently asked
What's the difference between TON Connect 2.0 and TonProof?
Why do I need Sign-in with TON if the address is already passed on connect?
Where to store the session token after a successful TonProof verify?
What is a nonce and why is it critical?
Which wallets support TonProof in 2026?
Can TonProof replace classic login/password auth?
How to verify a signature when the wallet address is a smart contract?
Related
- BasicsMay 17, 2026
TON Connect 2: What Changed in the Wallet Connection Protocol
How TON Connect 2 differs from v1 — JSON-RPC, deep and universal links, bridge servers, multi-wallet picker, and what developers should know in 2026.
- BasicsFeb 4, 2026
TON Connect: what it is, why it matters and how it works
TON Connect is the standard linking wallets to dApps on TON. How the protocol works, supporting wallets, the difference from WalletConnect
- BasicsDec 23, 2025
TON for developers: FunC, Tact and Tolk in 2026
The smart contract languages used on TON in 2026 — FunC, Tact, Tolk. Which to pick as a beginner, how they differ, what tooling you need and where to learn.
- BasicsMay 21, 2026
How to Accept TON Payments in a Telegram Bot — 2026 Business Guide
Five working ways to accept TON in a Telegram bot: Crypto Pay, Wallet Pay, xRocket Pay, custom TON Connect, Stars. Fees, integration time, when to use which.
- WalletsMay 16, 2026
Wallet V5: what's new and should you migrate
TON Wallet V5 contract explained: extensions, gasless transactions, batch transfers, security trade-offs and a step-by-step migration plan from V4 in 2026.
- BasicsMay 21, 2026
TON SDKs Compared: tonweb vs ton-core vs tonconnect-sdk (2026)
TypeScript SDKs for TON in 2026: tonweb (legacy), @ton/ton + @ton/core (recommended), @tonconnect/sdk. When to pick which and why.