TON Connect 2.0 + TonProof: Sign-in with TON — туториал (2026)
Как сделать Sign-in with TON через TON Connect 2.0 и TonProof: серверная верификация подписи, payload nonce, типичные ошибки и production-готовый код.
- Автор
- TON Adoption Team · исследовательская группа проекта
- Опубликовано
Содержание14разделов
- Зачем нужен TonProof
- Архитектура
- Backend: nonce, verify, session
- Backend: верификация подписи
- Frontend: TonConnect UI + tonProof
- Manifest для TonConnect
- Типичные ошибки и их фикс
- ”Bad signature” каждый раз
- Nonce expired сразу после connect
- Replay-атаки
- Кошелёк не поддерживает TonProof
- Production-checklist
- Безопасность session-token
- Итого
TL;DR. TonProof — это расширение TON Connect 2.0, превращающее кошелёк пользователя в полноценную систему Sign-in: вместо email/password пользователь подписывает nonce от вашего сервера, и сервер выдаёт session-token. Реализуется за 2-3 часа: frontend (TonConnectUI с tonProof в connectRequestParameters), сервер (генерация nonce + верификация подписи через @tonconnect/sdk). Главное — правильный nonce-pipeline (генерация → подпись → verify → blacklist), httpOnly-cookies, и handling ситуаций, когда кошелёк не поддерживает TonProof. Production-ready код ниже.
Зачем нужен TonProof
Базовый TON Connect 2.0 работает так:
- Пользователь сканирует QR/кликает на ваш dApp.
- Открывается кошелёк, пользователь подтверждает connect.
- Кошелёк возвращает вам адрес + public_key + state_init.
Проблема: эта последовательность может быть подделана. Любой человек может «подключить» вашему dApp адрес чужого кошелька (например, известного кита) — через модифицированный TON Connect клиент, который шлёт неподтверждённый адрес. Без подписи вы не знаете, кто реально подключился.
TonProof решает это: запрашивает у кошелька подписать специальное сообщение, в которое включён nonce от вашего сервера + домен dApp + временная метка. Подпись делается приватным ключом кошелька — а ключ есть только у настоящего владельца. На сервере вы верифицируете подпись против public_key — и теперь у вас есть криптографическое доказательство.
Архитектура
┌──────────────┐ ┌────────────────┐ ┌──────────────┐
│ 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.
Три endpoint’а на бэкенде:
GET /auth/nonce→ возвращает свежий noncePOST /auth/verify→ принимает результат connect от фронта, верифицирует, выдаёт session JWTGET /auth/me→ проверяет JWT cookie, возвращает данные пользователя
Backend: nonce, verify, session
Базовая реализация на Node.js + Express:
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: верификация подписи
Самая критичная часть — tonProofVerifySignature. На середину 2026 рекомендованный путь — использовать @ton/ton + @tonconnect/sdk-server:
import { Address, beginCell, Cell } from '@ton/core';
import { sign, getSecureRandomBytes } from '@ton/crypto';
import nacl from 'tweetnacl';
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);
const wc = addr.workChain;
// Construct the message that the wallet signed:
// sha256(prefix + len(domain) + domain + payload + timestamp + workchain + hash(address))
const message = Buffer.concat([
Buffer.from(TON_PROOF_PREFIX, 'utf8'),
Buffer.from(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 then 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;
}
Важные моменты:
- publicKey приходит от кошелька в hex, преобразуем в Buffer.
- address — bouncable string из кошелька, парсим через
@ton/core. - Подпись по схеме TonProof v2 (с префиксом, доменом, payload, timestamp, hash address).
- Ed25519 — стандартный TON-алгоритм.
В production используйте готовую библиотеку (например, tonProof-checker от tonkeeper) вместо переписывания crypto вручную — там обработаны edge-cases.
Frontend: TonConnect UI + tonProof
На стороне фронта подключите @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) {
// We got proof — verify on backend
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>
);
}
Ключевые шаги:
- Получите nonce при загрузке страницы или при инициализации кнопки.
- Передайте nonce в
tonConnectUI.setConnectRequestParameters({ value: { tonProof: nonce } })до того, как пользователь нажмёт connect. - Слушайте
onStatusChange. Если вwallet.connectItems.tonProofестьproof— отправьте на сервер на verify. - После успешного verify сервер положит cookie, и
/auth/meначнёт возвращать данные.
Manifest для TonConnect
Файл 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"
}
Иконка обязательно 180×180 PNG. URL должен совпадать с тем, что вы указали в proof.domain.value на бэкенде.
Типичные ошибки и их фикс
”Bad signature” каждый раз
Чаще всего:
- Wrong public key. Извлекли publicKey из неправильного места state_init. Решение: используйте поле
account.publicKeyнапрямую из TonConnect-результата. - Wrong domain. На бэкенде сравниваете с захардкоженным значением, которое не совпадает с фактическим. Решение: возьмите domain из
proof.domain.valueи проверьте против whitelist’а (production + staging доменов). - Wrong message construction. TonProof v2 имеет точный формат — проверяйте byte-by-byte с эталонной реализацией.
Nonce expired сразу после connect
- TTL слишком короткий. 1 минута может не хватить пользователю с медленным интернетом. 5 минут — разумный compromise.
- Серверы в разных TZ. Используйте
Date.now()везде (UTC ms epoch), неnew Date()-парсинг.
Replay-атаки
- Nonce blacklist отсутствует. Если вы не удаляете nonce из Map после verify — злоумышленник может переиспользовать proof. Всегда
nonces.delete(nonce)после успешной верификации. - JWT secret протёк. Если злоумышленник украл JWT_SECRET — он может выпускать токены сам. Ротируйте каждые 90 дней, храните в secret manager (Vault, AWS Secrets), не в .env репозитория.
Кошелёк не поддерживает TonProof
Старые версии xRocket, кастомные кошельки — могут вернуть connect без proof. Решение:
- В
onStatusChangeпроверяйте наличиеwallet.connectItems?.tonProof. - Если нет — fallback: либо отказ от login (для строгих dApp’ов), либо downgrade на классический connect без верификации (для UX-приоритетных).
Production-checklist
- Nonce из cryptographically-secure random (не
Math.random()) - Nonce TTL 5 минут + blacklist использованных
- Domain whitelist на сервере (включая staging)
- httpOnly cookies + Secure + SameSite=Lax
- JWT_SECRET в secret manager, не в repo
- Rate-limit на /auth/nonce и /auth/verify (10 rps/ip)
- Логирование failed verify (для детекции атак)
- Тесты на 4-5 разных кошельках (Tonkeeper, MyTonWallet, Wallet, OKX, Tonhub)
- Fallback UX, если кошелёк не поддерживает TonProof
Безопасность session-token
После выдачи JWT помните:
- Не показывайте JWT во фронте. httpOnly cookies — единственный способ; localStorage уязвим к XSS.
- Refresh-tokens. Для long-lived сессий используйте separate refresh-token (выпускается отдельно от access, хранится в DB, может быть revoked).
- Логаут. Очистите cookie + сохраните JWT-id в revocation list (если JWT — stateless).
Итого
TonProof — production-ready метод аутентификации для TON-dApp’ов в 2026. Реализация за 2-3 часа, безопасность на уровне крипто-подписи, UX в одно касание для пользователя. Главное — правильный nonce-pipeline и serverside-верификация подписи.
Используйте как основной метод login для DeFi/NFT-проектов, как опциональный второй фактор для traditional web. Для приёма платежей это первый шаг — после login можно вызывать sendTransaction через тот же TON Connect для payments.
Полный гайд по приёму платежей — см. «Как принимать TON в Telegram-боте».
Частые вопросы
В чём разница между TON Connect 2.0 и TonProof?
Зачем нужна Sign-in with TON, если адрес и так передаётся при connect?
Где хранить session-token после успешной верификации TonProof?
Что такое nonce и почему он критичен для безопасности?
Какие кошельки на TON поддерживают TonProof в 2026?
Можно ли использовать TonProof вместо классической аутентификации (логин/пароль)?
Что делать с подписью, если адрес кошелька — это смарт-контракт?
Похожие материалы
- Основы17 мая 2026 г.
TON Connect 2: что изменилось в протоколе подключения
Разбираем, чем TON Connect 2 отличается от первой версии, как устроен JSON-RPC, deep/universal links, bridge-сервера и поддержка multi-wallet picker. Обзор для пользователей и разработчиков.
- Основы28 янв. 2026 г.
TON Connect: что это, зачем нужен и как работает (2026)
TON Connect — стандарт подключения кошельков к dApps в TON. Разбираем как работает протокол, какие кошельки поддерживают
- Основы15 дек. 2025 г.
TON для разработчиков: знакомство с FunC, Tact и Tolk
Какие языки используются для смарт-контрактов TON в 2026 — FunC, Tact, Tolk. Что выбрать новичку, чем отличаются, какие инструменты нужны и где учиться.
- Основы21 мая 2026 г.
Как принимать TON в Telegram-боте для бизнеса в 2026
5 рабочих способов приёма TON-платежей в Telegram-боте: Crypto Pay, Wallet Pay, xRocket Pay, Tonkeeper Pay, кастомный с TON Connect. Что выбрать под нагрузку.
- Кошельки16 мая 2026 г.
Wallet V5: что нового и стоит ли мигрировать
Разбираем контракт Wallet V5 в TON: плагины, gasless-транзакции, batch-переводы, риски расширений и пошаговый план миграции с V4 в 2026 году.
- Основы21 мая 2026 г.
SDK для TON: tonweb vs ton-core vs tonconnect-sdk — что выбрать в 2026
Сравнение TypeScript-SDK для TON в 2026: tonweb (легаси), @ton/ton + @ton/core (рекомендуемый), @tonconnect/sdk. Когда что выбирать и почему.