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
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). - AppDomain —
lengthBytes(32-bit unsigned, little-endian) + the UTF-8-encoded app domain. Verify thatlengthBytesequals 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.
- Parse
walletStateInit— decode thewalletStateInitcell. - Identify the wallet version — compare the
codehash of thewalletStateInitwith known standard wallet contract codes. - Extract the public key — parse the
datasection according to the identified wallet version.
From on-chain get_public_key (fallback)
For non-standard wallets where walletStateInit parsing fails, query the blockchain:
- Invoke the
get_public_keyget method on the smart contract at the user's address. - Extract the public key from the result.
Final checks
After extracting the public key, verify:
- It matches the
publicKeyfield inTonAddressItemReply. contractAddress(workchain, walletStateInit)equals the user's address.- The domain equals the expected dApp domain and
lengthBytesequals its UTF-8 byte length. - The timestamp is within a short acceptance window, including a bound on future clock skew.
- The payload belongs to this login attempt, has not expired, and is consumed atomically so it cannot be reused.
- The connected network matches a network accepted by the application. The
ton_proofsignature 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' });
}
};