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.
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 aworkchain
(32-bit big-endian integer) and ahash
(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.
- Parse
walletStateInit
: Decode thewalletStateInit
cell. - Identify Wallet Version: Compare the
code
hash of thewalletStateInit
with known standard wallet contract codes to determine the wallet version. - Extract Public Key: Parse the
data
section of thewalletStateInit
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.
- Call get method: Invoke the
get_public_key
get method on the smart contract at the user's address. - 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:
- Public Key Consistency: The extracted public key must match the
publicKey
field in theTonAddressItemReply
. - 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.