TON DocsTON Docs
OnboardingNodesApplicationsAPIsContractsSmart contractsTolkTolk languageTVMTON Virtual MachineFoundationsBlockchain foundations

How to authenticate users with ton_proof

ton_proof lets a backend verify that a user controls the wallet account they connected. The wallet signs an application-provided payload with the account's private key; the backend checks the signature and issues a session token.

Verification flow

TON Proof verification flow: client requests payload, signs with wallet, and sends proof to backend

During wallet connection, the client receives a TonProofItemReplySuccess object and sends it to the backend:

type TonProofItemReplySuccess = {
  name: 'ton_proof';
  proof: {
    timestamp: number;     // Unix epoch time of the signing operation in seconds
    domain: {
      lengthBytes: number; // AppDomain length
      value: string;       // App domain name (URL part, without encoding)
    };
    signature: string;     // base64-encoded signature
    payload: string;       // payload from the request
  };
};

The SDK exposes timestamp as a number, while the TON Connect specification defines its wire value as a decimal string. Backend validation should accept both representations and normalize the value before verification.

Request a proof

Generate an unpredictable, single-use payload on the backend. Associate it with the login attempt and give it a short expiration time. Then pass the payload to setConnectRequestParameters before opening the wallet modal:

// Not runnable: initialize tonConnectUI and provide the API endpoint.
const payload = await fetch('/api/ton-proof/payload', {
  method: 'POST',
}).then((response) => response.text());

tonConnectUI.setConnectRequestParameters({
  state: 'ready',
  value: { tonProof: payload },
});

Keep the payload short. The SDK allows at most 128 UTF-8 bytes for the domain, 128 UTF-8 bytes for the payload, and 222 bytes for both combined.

Read the proof from the wallet status callback and send it to the backend together with wallet.account. A wallet that does not support ton_proof returns a connect-item error instead of a proof field, so handle both variants:

// Not runnable: initialize tonConnectUI and provide the API endpoint.
tonConnectUI.onStatusChange(async (wallet) => {
  const tonProof = wallet?.connectItems?.tonProof;
  if (!wallet || !tonProof || !('proof' in tonProof)) {
    return;
  }

  await fetch('/api/ton-proof/verify', {
    method: 'POST',
    headers: { 'content-type': 'application/json' },
    body: JSON.stringify({
      account: wallet.account,
      proof: tonProof.proof,
    }),
  });
});

Verify the signature

To verify, reconstruct the same message the wallet signed and check the signature against it.

Message assembly

Assemble the message exactly as the wallet did:

message = utf8_encode("ton-proof-item-v2/") ++
          Address ++
          AppDomain ++
          Timestamp ++
          Payload
  • Address — the user's wallet address: workchain (32-bit signed, big-endian) + hash (256-bit unsigned, big-endian).
  • AppDomainlengthBytes (32-bit unsigned, little-endian) + the UTF-8-encoded app domain. Verify that lengthBytes equals the encoded domain's byte length.
  • Timestamp — 64-bit little-endian Unix epoch time in seconds.
  • Payload — the exact UTF-8-encoded payload from the request. Use a backend-issued, single-use value to prevent replay attacks.

Signature verification

The signature is an Ed25519 signature over sha256(0xffff ++ utf8_encode("ton-connect") ++ sha256(message)):

signature = Ed25519Sign(
  privkey,
  sha256(0xffff ++ utf8_encode("ton-connect") ++ sha256(message))
)

The backend hashes the message, prepends 0xffff ++ "ton-connect", hashes again, and verifies with the user's public key.

Obtain the public key

From walletStateInit (preferred)

Extract the public key from the walletStateInit in TonAddressItemReply — no network call needed.

  1. Parse walletStateInit — decode the walletStateInit cell.
  2. Identify the wallet version — compare the code hash of the walletStateInit with known standard wallet contract codes.
  3. Extract the public key — parse the data section according to the identified wallet version.

From on-chain get_public_key (fallback)

For non-standard wallets where walletStateInit parsing fails, query the blockchain:

  1. Invoke the get_public_key get method on the smart contract at the user's address.
  2. Extract the public key from the result.

Final checks

After extracting the public key, verify:

  1. It matches the publicKey field in TonAddressItemReply.
  2. contractAddress(workchain, walletStateInit) equals the user's address.
  3. The domain equals the expected dApp domain and lengthBytes equals its UTF-8 byte length.
  4. The timestamp is within a short acceptance window, including a bound on future clock skew.
  5. The payload belongs to this login attempt, has not expired, and is consumed atomically so it cannot be reused.
  6. The connected network matches a network accepted by the application. The ton_proof signature does not bind the network.

Public-key extraction is wallet-version-specific. Identify the wallet by its code hash and parse the matching data layout; see tryExtractPublicKey for an implementation covering standard wallet versions.

React example

From the demo-dapp-with-wallet TonProofDemo component:

// Not runnable: copy the linked app and install its dependencies.
import React, { useCallback, useEffect, useRef, useState } from 'react';
import ReactJson from 'react-json-view';
import { TonProofDemoApi } from '../../TonProofDemoApi';
import { useTonConnectUI, useTonWallet, CHAIN } from '@tonconnect/ui-react';
import useInterval from '../../hooks/useInterval';

export const TonProofDemo = () => {
  const firstProofLoading = useRef<boolean>(true);

  const [data, setData] = useState({});
  const wallet = useTonWallet();
  const [authorized, setAuthorized] = useState(false);
  const [tonConnectUI] = useTonConnectUI();

  const recreateProofPayload = useCallback(async () => {
    if (firstProofLoading.current) {
      tonConnectUI.setConnectRequestParameters({ state: 'loading' });
      firstProofLoading.current = false;
    }

    const payload = await TonProofDemoApi.generatePayload();

    if (payload) {
      tonConnectUI.setConnectRequestParameters({ state: 'ready', value: payload });
    } else {
      tonConnectUI.setConnectRequestParameters(null);
    }
  }, [tonConnectUI, firstProofLoading]);

  if (firstProofLoading.current) {
    recreateProofPayload();
  }

  useInterval(recreateProofPayload, TonProofDemoApi.refreshIntervalMs);

  useEffect(
    () =>
      tonConnectUI.onStatusChange(async (w) => {
        if (!w || w.account.chain === CHAIN.TESTNET) {
          TonProofDemoApi.reset();
          setAuthorized(false);
          return;
        }

        if (w.connectItems?.tonProof && 'proof' in w.connectItems.tonProof) {
          await TonProofDemoApi.checkProof(w.connectItems.tonProof.proof, w.account);
        }

        if (!TonProofDemoApi.accessToken) {
          tonConnectUI.disconnect();
          setAuthorized(false);
          return;
        }

        setAuthorized(true);
      }),
    [tonConnectUI],
  );

  const handleClick = useCallback(async () => {
    if (!wallet) {
      return;
    }
    const response = await TonProofDemoApi.getAccountInfo(wallet.account);
    setData(response);
  }, [wallet]);

  if (!authorized) {
    return null;
  }

  return (
    <div className="ton-proof-demo">
      <h3>Demo backend API with ton_proof verification</h3>
      {authorized ? (
        <button onClick={handleClick}>Call backend getAccountInfo()</button>
      ) : (
        <div className="ton-proof-demo__error">Connect wallet to call API</div>
      )}
      <ReactJson src={data} name="response" theme="ocean" />
    </div>
  );
};

Backend example

From demo-dapp-with-react-ui:

Proof request schema

// Not runnable: add this file to the linked server and install its dependencies.
import { CHAIN } from '@tonconnect/ui-react';
import zod from 'zod';

export const CheckProofRequest = zod.object({
  address: zod.string(),
  network: zod.enum([CHAIN.MAINNET, CHAIN.TESTNET]),
  public_key: zod.string(),
  proof: zod.object({
    timestamp: zod.coerce.number().int().nonnegative(),
    domain: zod.object({
      lengthBytes: zod.number(),
      value: zod.string(),
    }),
    payload: zod.string(),
    signature: zod.string(),
    state_init: zod.string(),
  }),
  payloadToken: zod.string(),
});

export type CheckProofRequestDto = zod.infer<typeof CheckProofRequest>;

Proof verification service

// Not runnable: add the referenced DTO and wallet parsing helpers.
import { getSecureRandomBytes, sha256 } from '@ton/crypto';
import { Address, Cell, contractAddress, loadStateInit } from '@ton/ton';
import { Buffer } from 'buffer';
import { sign } from 'tweetnacl';
import { CheckProofRequestDto } from '../dto/check-proof-request-dto';
import { tryParsePublicKey } from '../wrappers/wallets-data';

const tonProofPrefix = 'ton-proof-item-v2/';
const tonConnectPrefix = 'ton-connect';
const allowedDomains = [
  'tonconnect-demo-dapp-with-react-ui.vercel.app',
  'ton-connect.github.io',
  'localhost:5173',
];
const validAuthTime = 15 * 60; // 15 minutes

export class TonProofService {
  /** Generate random bytes for the payload. */
  public async generateRandomBytes(): Promise<Buffer> {
    return await getSecureRandomBytes(32);
  }

  /**
   * Verify the ton_proof payload.
   * Reference: https://github.com/ton-blockchain/ton-connect/blob/main/spec/connect.md#address-proof-signature-ton_proof
   */
  public async checkProof(
    payload: CheckProofRequestDto,
    getWalletPublicKey: (address: string) => Promise<Buffer | null>,
  ): Promise<boolean> {
    try {
      const stateInit = loadStateInit(
        Cell.fromBase64(payload.proof.state_init).beginParse(),
      );

      // 1. Try to obtain public key from stateInit.
      // 2. Fall back to get_public_key get method on-chain.
      let publicKey =
        tryParsePublicKey(stateInit) ??
        (await getWalletPublicKey(payload.address));
      if (!publicKey) {
        return false;
      }

      // Verify public key matches the one provided by TonAddressItemReply
      const wantedPublicKey = Buffer.from(payload.public_key, 'hex');
      if (!publicKey.equals(wantedPublicKey)) {
        return false;
      }

      // Verify address matches the stateInit hash
      const wantedAddress = Address.parse(payload.address);
      const address = contractAddress(wantedAddress.workChain, stateInit);
      if (!address.equals(wantedAddress)) {
        return false;
      }

      if (!allowedDomains.includes(payload.proof.domain.value)) {
        return false;
      }

      const domainBytes = Buffer.from(payload.proof.domain.value, 'utf8');
      if (payload.proof.domain.lengthBytes !== domainBytes.length) {
        return false;
      }

      const now = Math.floor(Date.now() / 1000);
      if (Math.abs(now - payload.proof.timestamp) > validAuthTime) {
        return false;
      }

      const message = {
        workchain: address.workChain,
        address: address.hash,
        domain: {
          lengthBytes: payload.proof.domain.lengthBytes,
          value: payload.proof.domain.value,
        },
        signature: Buffer.from(payload.proof.signature, 'base64'),
        payload: payload.proof.payload,
        stateInit: payload.proof.state_init,
        timestamp: payload.proof.timestamp,
      };

      const wc = Buffer.alloc(4);
      wc.writeInt32BE(message.workchain, 0);

      const ts = Buffer.alloc(8);
      ts.writeBigUInt64LE(BigInt(message.timestamp), 0);

      const dl = Buffer.alloc(4);
      dl.writeUInt32LE(message.domain.lengthBytes, 0);

      // message = utf8_encode("ton-proof-item-v2/") ++ Address ++ AppDomain ++ Timestamp ++ Payload
      const msg = Buffer.concat([
        Buffer.from(tonProofPrefix),
        wc,
        message.address,
        dl,
        domainBytes,
        ts,
        Buffer.from(message.payload, 'utf8'),
      ]);

      const msgHash = Buffer.from(await sha256(msg));

      // signature = Ed25519Sign(privkey, sha256(0xffff ++ utf8_encode("ton-connect") ++ sha256(message)))
      const fullMsg = Buffer.concat([
        Buffer.from([0xff, 0xff]),
        Buffer.from(tonConnectPrefix),
        msgHash,
      ]);

      const result = Buffer.from(await sha256(fullMsg));

      return sign.detached.verify(result, message.signature, publicKey);
    } catch (e) {
      return false;
    }
  }
}

API endpoints

The demo below verifies the payload token's signature and expiration, but that alone does not make it single-use. In production, store a nonce identifier on the backend and consume it atomically during checkProof; reject every later request that uses the same identifier.

// Not runnable: add the linked server's services, utilities, and DTO.
import { sha256 } from '@ton/crypto';
import { HttpResponseResolver } from 'msw';
import { CheckProofRequest } from '../dto/check-proof-request-dto';
import { TonApiService } from '../services/ton-api-service';
import { TonProofService } from '../services/ton-proof-service';
import { badRequest, ok } from '../utils/http-utils';
import { createAuthToken, verifyToken } from '../utils/jwt';

// POST /api/check_proof
export const checkProof: HttpResponseResolver = async ({ request }) => {
  try {
    const body = CheckProofRequest.parse(await request.json());

    const client = TonApiService.create(body.network);
    const service = new TonProofService();

    const isValid = await service.checkProof(body, (address) =>
      client.getWalletPublicKey(address),
    );
    if (!isValid) {
      return badRequest({ error: 'Invalid proof' });
    }

    const payloadTokenHash = body.proof.payload;
    const payloadToken = body.payloadToken;
    if (!(await verifyToken(payloadToken))) {
      return badRequest({ error: 'Invalid token' });
    }
    if ((await sha256(payloadToken)).toString('hex') !== payloadTokenHash) {
      return badRequest({ error: 'Invalid payload token hash' });
    }

    const token = await createAuthToken({
      address: body.address,
      network: body.network,
    });

    return ok({ token: token });
  } catch (e) {
    console.error(e);
    return badRequest({ error: 'Invalid request' });
  }
};

Verification examples in other languages

See also

On this page