Перейти к основному содержимому

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.
  • Timestamp: A 64-bit Unix timestamp.
  • Payload: A unique string, often a nonce, to prevent replay attacks.

Signature verification

The final signature is a hash of the assembled message, prefixed with 0xffff and ton-connect:

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: The hash of the walletStateInit must match the user's address.

React example

  1. Add a token provider to the root of your app:
function App() {
const [token, setToken] = useState<string | null>(null);

return (
<BackendTokenContext.Provider value={{token, setToken}}>
{ /* Your app */ }
</BackendTokenContext.Provider>
)
}
  1. Implement authentication on the front end with backend integration:
Example
import {useContext, useEffect, useRef} from "react";
import {BackendTokenContext} from "./BackendTokenContext";
import {useIsConnectionRestored, useTonConnectUI, useTonWallet} from "@tonconnect/ui-react";
import {backendAuth} from "./backend-auth";

const localStorageKey = 'my-dapp-auth-token';
const payloadTTLMS = 1000 * 60 * 20;

export function useBackendAuth() {
const { setToken } = useContext(BackendTokenContext);
const isConnectionRestored = useIsConnectionRestored();
const wallet = useTonWallet();
const [tonConnectUI] = useTonConnectUI();
const interval = useRef<ReturnType<typeof setInterval> | undefined>();

useEffect(() => {
if (!isConnectionRestored || !setToken) {
return;
}

clearInterval(interval.current);

if (!wallet) {
localStorage.removeItem(localStorageKey);
setToken(null);

const refreshPayload = async () => {
tonConnectUI.setConnectRequestParameters({ state: 'loading' });

const value = await backendAuth.generatePayload();
if (!value) {
tonConnectUI.setConnectRequestParameters(null);
} else {
tonConnectUI.setConnectRequestParameters({state: 'ready', value});
}
}

refreshPayload();
setInterval(refreshPayload, payloadTTLMS);
return;
}

const token = localStorage.getItem(localStorageKey);
if (token) {
setToken(token);
return;
}

if (wallet.connectItems?.tonProof && !('error' in wallet.connectItems.tonProof)) {
backendAuth.checkProof(wallet.connectItems.tonProof.proof, wallet.account).then(result => {
if (result) {
setToken(result);
localStorage.setItem(localStorageKey, result);
} else {
alert('Please try another wallet');
tonConnectUI.disconnect();
}
})
} else {
alert('Please try another wallet');
tonConnectUI.disconnect();
}

}, [wallet, isConnectionRestored, setToken])
}

Backend example

The following example is adapted from the demo dApp with React UI and demonstrates a complete server-side verification implementation. For this code to function, your project must replicate a similar structure.

You can review our example showcasing the key methods:

Full backend verification example
import { Address, Cell, contractAddress, loadStateInit } from '@ton/core';
import { sign } from 'tweetnacl';
import { sha256 } from 'js-sha256';

// This DTO is simplified for demonstration.
// See the full structure in the demo dApp repository.
interface CheckProofRequestDto {
address: string;
public_key: string;
proof: {
timestamp: number;
domain: {
lengthBytes: number;
value: string;
};
signature: string;
payload: string;
state_init: string;
};
}

const tonProofPrefix = 'ton-proof-item-v2/';
const tonConnectPrefix = 'ton-connect';
const allowedDomains = ['YOUR_APP_DOMAIN.com']; // Replace with your app's domain
const validAuthTime = 600; // 10 minutes

// Attempts to extract the public key from a wallet's stateInit.
function tryParsePublicKey(stateInit: Cell): Buffer | null {
try {
const stateInitData = loadStateInit(stateInit.beginParse());
if (stateInitData.data) {
const reader = stateInitData.data.beginParse();
reader.skip(32); // seqno
return reader.loadBuffer(32); // public key
}
} catch (e) {
console.error("Failed to parse public key from stateInit", e);
}
return null;
}

// This function checks the proof and is based on the implementation
// in the official demo dApp.
export async function checkProof(payload: CheckProofRequestDto, getWalletPublicKey: (address: string) => Promise<Buffer | null>): Promise<boolean> {
try {
const stateInitCell = Cell.fromBase64(payload.proof.state_init);
const stateInit = loadStateInit(stateInitCell.beginParse());

// Obtain the public key from stateInit or via a get-method call.
let publicKey = tryParsePublicKey(stateInitCell) ?? await getWalletPublicKey(payload.address);
if (!publicKey) {
return false;
}

// Verify that the public key matches the one from the payload.
const wantedPublicKey = Buffer.from(payload.public_key, 'hex');
if (!publicKey.equals(wantedPublicKey)) {
return false;
}

// Verify that the wallet address matches the one derived from stateInit.
const wantedAddress = Address.parse(payload.address);
const address = contractAddress(wantedAddress.workChain, stateInit);
if (!address.equals(wantedAddress)) {
return false;
}

// Verify that the request originates from an allowed domain.
if (!allowedDomains.includes(payload.proof.domain.value)) {
return false;
}

// Verify that the proof's timestamp is recent.
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
};

// Reconstruct the message that was signed.
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);

const msg = Buffer.concat([
Buffer.from(tonProofPrefix),
wc,
message.address,
dl,
Buffer.from(message.domain.value),
ts,
Buffer.from(message.payload),
]);

// Hash the message and verify the signature.
const msgHash = Buffer.from(sha256.create().update(msg).digest());

const fullMsg = Buffer.concat([
Buffer.from([0xff, 0xff]),
Buffer.from(tonConnectPrefix),
msgHash,
]);

const result = Buffer.from(sha256.create().update(fullMsg).digest());

return sign.detached.verify(result, message.signature, publicKey);
} catch (e) {
console.error(e);
return false;
}
}

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?