跳到主要内容

Signed-in user verification

This tutorial provides a step-by-step guide for backend developers to verify user identities using ton_proof. Implementing this verification process ensures that a user genuinely owns the wallet address they claim, which is crucial for delivering personalized or protected data.

We will focus on the server-side logic required to validate the ton_proof payload sent from the client.

Verification flow

The verification process begins after a user connects their wallet and the client-side application receives a ton_proof object. This object is then sent to your backend for validation.

TON Proof SchemeTON Proof Scheme

The backend must perform a series of checks to confirm the authenticity of the proof.

Step 1: Deconstruct the ton_proof payload

Upon receiving the TonProofItemReplySuccess object from the client, the first action is to parse its structure. This object contains the proof of ownership that your server must validate.

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

This structure provides the necessary components for verification: a timestamp to prevent replay attacks, the domain of the application, the signature to verify, and a payload which may contain a nonce.

Step 2: Verify the signature and message

The core of the verification process is to reconstruct the message that was signed by the wallet and then verify the provided signature against it.

Message assembly

The message signed by the wallet follows a strict format. Your backend must assemble an identical message to reproduce the hash that was signed.

The message is a concatenation of several fields:

message = utf8_encode("ton-proof-item-v2/") ++
Address ++
AppDomain ++
Timestamp ++
Payload
  • Address: The user's wallet address, comprising a workchain (32-bit big-endian integer) and a hash (256-bit big-endian integer).
  • AppDomain: The domain of your application, prefixed with its length. lengthBytes is a 32-bit little-endian integer (number of bytes).
  • Timestamp: A 64-bit little-endian integer containing Unix epoch time in seconds.
  • Payload: A unique string, often a nonce, 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)))

Your backend must perform these steps in reverse: hash the message, prepend the prefixes, hash the result, and then use the user's public key to verify the signature.

Step 3: Obtain and verify the public key

A critical component for signature verification is the user's public key. There are two methods to obtain it, with a clear order of preference to ensure efficiency and security.

Primary method: Extract from walletStateInit

The recommended approach is to extract the public key directly from the walletStateInit provided in the TonAddressItemReply. This avoids a network call to the blockchain.

  1. Parse walletStateInit: Decode the walletStateInit cell.
  2. Identify Wallet Version: Compare the code hash of the walletStateInit with known standard wallet contract codes to determine the wallet version.
  3. Extract Public Key: Parse the data section of the walletStateInit according to the identified wallet version to retrieve the public key.

Fallback method: On-chain get_public_key

If the public key cannot be extracted from walletStateInit (e.g., for older or non-standard wallets), the fallback is to query the blockchain.

  1. Call get method: Invoke the get_public_key get method on the smart contract at the user's address.
  2. Extract Public Key: The result of this call will be the public key.

Final checks

Regardless of the method used, two final checks are mandatory:

  1. Public Key Consistency: The extracted public key must match the publicKey field in the TonAddressItemReply.
  2. Address Integrity: Derive the address as contractAddress(workchain, walletStateInit) and ensure it equals the user's address.

Note: The helper used in the example to extract a public key from stateInit is simplified; replace it with version‑aware parsing after determining the wallet type (for example, via code hash) as described above.

React example

The example below is the same logic used in demo-dapp-with-wallet TonProofDemo component.

Example
import React, {useCallback, useEffect, useRef, useState} from 'react';
import ReactJson from 'react-json-view';
import './style.scss';
import {TonProofDemoApi} from '../../TonProofDemoApi';
import {useTonConnectUI, useTonWallet} from '@tonconnect/ui-react';
import {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

The following code is copied from the local demo dApp with React UI repository, making the example identical to the demo implementation.

Full backend verification example

File: src/server/dto/check-proof-request-dto.ts

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.number(),
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>;

File: src/server/services/ton-proof-service.ts

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 minute

export class TonProofService {

/**
* Generate a random bytes.
*/
public async generateRandomBytes(): Promise<Buffer> {
return await getSecureRandomBytes(32);
}

/**
* Reference implementation of the checkProof method:
* https://github.com/ton-blockchain/ton-connect/blob/main/requests-responses.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. First, try to obtain public key via get_public_key get-method on smart contract deployed at Address.
// 2. If the smart contract is not deployed yet, or the get-method is missing, you need:
// 2.1. Parse TonAddressItemReply.walletStateInit and get public key from stateInit. You can compare the walletStateInit.code
// with the code of standard wallets contracts and parse the data according to the found wallet version.
let publicKey = tryParsePublicKey(stateInit) ?? await getWalletPublicKey(payload.address);
if (!publicKey) {
return false;
}

// 2.2. Check that TonAddressItemReply.publicKey equals to obtained public key
const wantedPublicKey = Buffer.from(payload.public_key, 'hex');
if (!publicKey.equals(wantedPublicKey)) {
return false;
}

// 2.3. Check that TonAddressItemReply.walletStateInit.hash() equals to TonAddressItemReply.address. .hash() means BoC 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 now = Math.floor(Date.now() / 1000);
if (now - validAuthTime > payload.proof.timestamp) {
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.writeUInt32BE(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,
Buffer.from(message.domain.value),
ts,
Buffer.from(message.payload),
]);

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;
}
}

}

File: src/server/api/check-proof.ts

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";

/**
* Checks the proof and returns an access token.
*
* 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) {
return badRequest({error: 'Invalid request', trace: e});
}
};

File: src/server/api/generate-payload.ts

import {sha256} from "@ton/crypto";
import {HttpResponseResolver} from "msw";
import {TonProofService} from "../services/ton-proof-service";
import {badRequest, ok} from "../utils/http-utils";
import {createPayloadToken} from "../utils/jwt";

/**
* Generates a payload for ton proof.
*
* POST /api/generate_payload
*/
export const generatePayload: HttpResponseResolver = async () => {
try {
const service = new TonProofService();

const randomBytes = await service.generateRandomBytes();
const payloadToken = await createPayloadToken({
randomBytes: randomBytes.toString('hex')
});
const payloadTokenHash = (await sha256(payloadToken)).toString('hex');

return ok({
payloadToken: payloadToken,
payloadTokenHash: payloadTokenHash,
});
} catch (e) {
return badRequest({error: 'Invalid request', trace: e});
}
};

Conclusion

This tutorial outlined the server-side process for ton_proof verification. By following these steps, you can build a robust authentication system that securely validates user ownership of a TON wallet. The key takeaways are the importance of the specific message structure, the dual-method approach for public key retrieval, and the final signature verification.

For further development, you could extend this verification logic to manage user sessions, issue JWT tokens for authenticated API access, or integrate with more complex authorization schemes based on wallet addresses.

Verification examples

See also

Was this article useful?