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