DEV Community

Cover image for TON Connect 2.0 + TonProof: Sign-in with TON Tutorial (2026)
ton-adoption
ton-adoption

Posted on • Originally published at ton-adoption.xyz on

TON Connect 2.0 + TonProof: Sign-in with TON Tutorial (2026)

TON Connect 2.0 + TonProof: Sign-in with TON Tutorial (2026)

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:

  1. User scans QR / clicks the dApp.
  2. Wallet opens, user confirms connect.
  3. 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:

  1. Get a nonce on page load or on button init.
  2. Pass it to tonConnectUI.setConnectRequestParameters({ value: { tonProof: nonce } }) before the user clicks connect.
  3. Listen to onStatusChange. If wallet.connectItems.tonProof has proof — POST to server for verify.
  4. After successful verify the server sets a cookie; /auth/me starts 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.publicKey directly from the TonConnect result.
  • Wrong domain. Server compares to a hardcoded value that doesn’t match. Fix: take domain from proof.domain.value and 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), not new 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 for wallet.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.

Top comments (0)