К основному содержанию
T TON Adoption
Основы DEV · 2026

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 · исследовательская группа проекта
Опубликовано
7 мин. чтения

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 работает так:

  1. Пользователь сканирует QR/кликает на ваш dApp.
  2. Открывается кошелёк, пользователь подтверждает connect.
  3. Кошелёк возвращает вам адрес + 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 → возвращает свежий nonce
  • POST /auth/verify → принимает результат connect от фронта, верифицирует, выдаёт session JWT
  • GET /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>
  );
}

Ключевые шаги:

  1. Получите nonce при загрузке страницы или при инициализации кнопки.
  2. Передайте nonce в tonConnectUI.setConnectRequestParameters({ value: { tonProof: nonce } }) до того, как пользователь нажмёт connect.
  3. Слушайте onStatusChange. Если в wallet.connectItems.tonProof есть proof — отправьте на сервер на verify.
  4. После успешного 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 — это протокол для соединения кошелька пользователя с dApp/dApp-mini-app (UI поверх TON-кошелька + механика sendTransaction). TonProof — это extension к TON Connect 2.0, который позволяет dApp получить криптографическую подпись от кошелька пользователя, доказывающую владение приватным ключом. Без TonProof вы знаете только адрес кошелька (любой может назвать чужой). С TonProof вы знаете, что подключенный — действительно владелец.
Адрес — публичный идентификатор. Любой человек может подключить вашему dApp кошелёк, в котором лежит чужой адрес (через расширения или модифицированный кошелёк-клиент). Без подписи вы не отличите владельца от наблюдателя. С TonProof кошелёк подписывает специальное сообщение с nonce от вашего сервера; ваш сервер верифицирует подпись от приватного ключа того адреса — и только тогда выдаёт session-token. Это аналог OAuth, но без посредника.
Стандартный паттерн — JWT (или session-id в Redis), который вы выдаёте после verify и кладёте в httpOnly cookie (защита от XSS) с SameSite=Lax (защита от CSRF). Время жизни — 24-48 часов. На каждом защищённом запросе достаёте из cookie, валидируете JWT, разрешаете доступ. Никогда не храните payload или private key в localStorage и не доверяйте подписи в каждом запросе — это дорого по cpu и подвержено replay-атакам без nonce.
Nonce (number-used-once) — это уникальная одноразовая строка, которую сервер генерирует и просит кошелёк подписать. Без nonce злоумышленник может перехватить подпись и переиспользовать её для входа от вашего имени (replay-атака). С nonce подпись валидна только для одного login-attempt'а. Срок жизни nonce должен быть короткий (5 минут максимум), и сервер должен хранить уже использованные nonce'ы в blacklist'е, чтобы не было reuse.
Все основные: Tonkeeper (с 2.0), MyTonWallet (с 1.5+), Tonhub, Bitget Wallet, OKX Wallet, Wallet-в-Telegram. Не поддерживают пока: некоторые старые версии custodial-кошельков (xRocket до 2.5). При connect через TON Connect 2.0 dApp может проверить через features.signProof — есть ли поддержка; если нет — fallback на простой connect без proof.
Да, и это растущий паттерн. Для dApp'ов с onchain-функциональностью (DeFi, NFT-маркетплейсы, gaming) — TonProof становится единственным методом аутентификации, без email/password. Для traditional web-app — TonProof работает как второй фактор или альтернативный путь. Преимущество: пользователю не нужно помнить пароль; недостаток: при потере кошелька теряется аккаунт. Для гибрида подходит схема 'TonProof + email backup'.
На TON каждый кошелёк — это смарт-контракт (Wallet v3/v4/v5). Подпись — это сигнатура secp256k1 + ed25519 от private key, привязанного к кошельку. Серверная верификация: получаете state_init (или public_key из state) кошелька с tonapi.io, извлекаете public_key и валидируете подпись через стандартную TonProof-библиотеку. Версия кошелька (W3/W4/W5) влияет на формат state_init, но публичный ключ всегда извлекается.

Похожие материалы