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 signing digest is 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. Mainnet and testnet use the empty signature domain. Other global IDs use the corresponding L2 signature domain.

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

Adapted from the SDK demo frontend:

// 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

Adapted from the SDK demo backend.

Proof request schema

// Not runnable: add this file to the linked server and install its dependencies.
import zod from 'zod';

export const CheckProofRequest = zod.object({
  address: zod.string(),
  network: zod.string().regex(/^-?\d+$/),
  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,
  domainSignVerify,
  loadStateInit,
  type SignatureDomain,
} from '@ton/ton';
import { Buffer } from 'buffer';
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

function signatureDomain(network: string): SignatureDomain {
  return network === '-239' || network === '-3'
    ? { type: 'empty' }
    : { type: 'l2', globalId: Number(network) };
}

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 domainSignVerify({
        data: result,
        signature: message.signature,
        publicKey,
        domain: signatureDomain(payload.network),
      });
    } 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

Community examples

The following examples were created by the community — they may be out of date or unsuitable for production. Proceed with caution. Always test thoroughly on testnet before using real funds.

See also

On this page