Security best practices
Build secure Stacks applications with proven patterns
Overview
Security in blockchain applications is paramount—smart contracts are immutable, transactions are irreversible, and vulnerabilities can lead to permanent loss of funds. This guide covers essential security practices for building robust Stacks applications.
Transaction security
Always validate and constrain transaction behavior:
async function secureTransaction() {// 1. Validate inputs before transactionconst recipient = validateAddress(inputAddress);const amount = validateAmount(inputAmount);// 2. Check current stateconst balance = await getBalance(senderAddress);if (balance < amount + estimatedFee) {throw new Error('Insufficient balance including fees');}// 3. Add comprehensive post-conditionsconst postConditions = [makeStandardSTXPostCondition(senderAddress,FungibleConditionCode.Equal,amount),];// 4. Use strict post-condition modeconst txOptions = {recipient,amount,postConditions,postConditionMode: PostConditionMode.Deny,senderKey: privateKey,network,anchorMode: AnchorMode.Any,};// 5. Handle errors gracefullytry {const tx = await makeSTXTokenTransfer(txOptions);return await broadcastTransaction(tx, network);} catch (error) {console.error('Transaction failed safely:', error);throw error;}}
Input validation
Never trust user input—validate everything:
import { validateStacksAddress } from '@stacks/transactions';function validateTransactionInputs(params: any) {// Validate addressesif (!validateStacksAddress(params.recipient)) {throw new Error('Invalid recipient address');}// Validate amountsconst amount = Number(params.amount);if (isNaN(amount) || amount <= 0) {throw new Error('Invalid amount');}// Check for integer overflowif (amount > Number.MAX_SAFE_INTEGER) {throw new Error('Amount too large');}// Validate contract namesif (params.contractName && !/^[a-zA-Z0-9-]+$/.test(params.contractName)) {throw new Error('Invalid contract name');}// Validate function argumentsif (params.functionArgs) {params.functionArgs.forEach((arg: any, index: number) => {if (arg === null || arg === undefined) {throw new Error(`Invalid argument at position ${index}`);}});}return {recipient: params.recipient,amount,contractName: params.contractName,functionArgs: params.functionArgs,};}
Private key management
Secure key handling is critical:
// Never do this!const privateKey = 'ef234807...'; // ❌ Hardcoded key// Better: Environment variablesconst privateKey = process.env.STACKS_PRIVATE_KEY;if (!privateKey) {throw new Error('Private key not configured');}// Best: Secure key management serviceimport { SecretManagerServiceClient } from '@google-cloud/secret-manager';async function getPrivateKey(): Promise<string> {const client = new SecretManagerServiceClient();const [version] = await client.accessSecretVersion({name: 'projects/my-project/secrets/stacks-key/versions/latest',});return version.payload?.data?.toString() || '';}// For browser apps: Never store keys// Always use wallet connections insteadfunction connectWallet() {showConnect({appDetails: {name: 'My App',icon: '/logo.png',},onFinish: () => {// Wallet manages keys securely},userSession,});}
Contract interaction safety
Implement defensive patterns when calling contracts:
class SafeContractCaller {constructor(private network: StacksNetwork,private contractAddress: string,private contractName: string) {}async safeCall(functionName: string,functionArgs: ClarityValue[],options: {expectedReturnType?: ClarityType;maxFee?: number;postConditions?: PostCondition[];} = {}) {// 1. Validate contract existsawait this.validateContract();// 2. Check function exists and matches expected signatureawait this.validateFunction(functionName, functionArgs);// 3. Simulate call first (read-only)const simulation = await callReadOnlyFunction({network: this.network,contractAddress: this.contractAddress,contractName: this.contractName,functionName,functionArgs,senderAddress: this.contractAddress,});// 4. Validate simulation resultif (options.expectedReturnType) {this.validateReturnType(simulation, options.expectedReturnType);}// 5. Build transaction with constraintsconst txOptions = {contractAddress: this.contractAddress,contractName: this.contractName,functionName,functionArgs,postConditions: options.postConditions || [],postConditionMode: PostConditionMode.Deny,fee: options.maxFee || 1000,network: this.network,anchorMode: AnchorMode.Any,};return makeContractCall(txOptions);}private async validateContract() {const response = await fetch(`${this.network.coreApiUrl}/v2/contracts/interface/${this.contractAddress}/${this.contractName}`);if (!response.ok) {throw new Error('Contract does not exist or is not accessible');}}private async validateFunction(name: string, args: ClarityValue[]) {// Implement function signature validation}private validateReturnType(value: ClarityValue, expectedType: ClarityType) {if (value.type !== expectedType) {throw new Error(`Unexpected return type: ${value.type}`);}}}
Reentrancy protection
Prevent reentrancy attacks in your contracts and calls:
class ReentrancyGuard {private locked = new Set<string>();async protectedCall<T>(key: string,operation: () => Promise<T>): Promise<T> {if (this.locked.has(key)) {throw new Error('Reentrancy detected');}this.locked.add(key);try {return await operation();} finally {this.locked.delete(key);}}}// Usageconst guard = new ReentrancyGuard();async function swapTokens() {return guard.protectedCall('swap', async () => {// Perform swap operationsconst tx1 = await approveTokens();await waitForConfirmation(tx1);const tx2 = await executeSwap();await waitForConfirmation(tx2);return tx2;});}
Network security
Protect against network-level attacks:
class SecureNetworkClient {private readonly timeout = 30000; // 30 secondsprivate readonly maxRetries = 3;async secureRequest(url: string, options: RequestInit = {}) {// 1. Validate URLconst validatedUrl = this.validateUrl(url);// 2. Add security headersconst secureOptions = {...options,headers: {...options.headers,'X-Request-ID': crypto.randomUUID(),'X-Timestamp': Date.now().toString(),},};// 3. Implement retry with backofflet lastError;for (let i = 0; i < this.maxRetries; i++) {try {const controller = new AbortController();const timeoutId = setTimeout(() => controller.abort(), this.timeout);const response = await fetch(validatedUrl, {...secureOptions,signal: controller.signal,});clearTimeout(timeoutId);// 4. Validate responseif (!response.ok) {throw new Error(`HTTP error! status: ${response.status}`);}return response;} catch (error) {lastError = error;// Exponential backoffawait new Promise(resolve => setTimeout(resolve, 1000 * Math.pow(2, i)));}}throw lastError;}private validateUrl(url: string): string {const parsed = new URL(url);// Only allow HTTPS in productionif (process.env.NODE_ENV === 'production' && parsed.protocol !== 'https:') {throw new Error('Only HTTPS URLs allowed in production');}// Whitelist allowed domainsconst allowedDomains = ['api.hiro.so', 'api.testnet.hiro.so'];if (!allowedDomains.includes(parsed.hostname)) {throw new Error('Domain not whitelisted');}return url;}}
Front-running protection
Protect against transaction front-running:
async function protectedSwap(amountIn: number,minAmountOut: number,deadline: number) {// 1. Add commitment phaseconst commitment = crypto.randomBytes(32);const commitmentHash = crypto.createHash('sha256').update(commitment).digest('hex');// 2. Submit commitment firstconst commitTx = await makeContractCall({contractAddress: dexContract,contractName: 'dex-v1',functionName: 'commit-swap',functionArgs: [bufferCV(Buffer.from(commitmentHash, 'hex')),uintCV(deadline),],senderKey: privateKey,network,anchorMode: AnchorMode.Any,});await broadcastTransaction(commitTx, network);await waitForConfirmation(commitTx.txid);// 3. Reveal and executeconst revealTx = await makeContractCall({contractAddress: dexContract,contractName: 'dex-v1',functionName: 'reveal-and-swap',functionArgs: [bufferCV(commitment),uintCV(amountIn),uintCV(minAmountOut),],postConditions: [// Strict conditions prevent manipulationmakeStandardFungiblePostCondition(userAddress,FungibleConditionCode.Equal,amountIn,tokenInAsset),makeStandardFungiblePostCondition(userAddress,FungibleConditionCode.GreaterEqual,-minAmountOut,tokenOutAsset),],postConditionMode: PostConditionMode.Deny,senderKey: privateKey,network,anchorMode: AnchorMode.Any,});return broadcastTransaction(revealTx, network);}
Error handling patterns
Implement comprehensive error handling:
class TransactionError extends Error {constructor(message: string,public code: string,public txId?: string,public details?: any) {super(message);this.name = 'TransactionError';}}async function robustTransactionHandler(operation: () => Promise<any>) {try {return await operation();} catch (error: any) {// Categorize errorsif (error.message?.includes('Insufficient balance')) {throw new TransactionError('Not enough funds to complete transaction','INSUFFICIENT_FUNDS',error.txId);}if (error.message?.includes('ContractNotFound')) {throw new TransactionError('Smart contract not found','CONTRACT_NOT_FOUND');}if (error.message?.includes('NetworkError')) {throw new TransactionError('Network connection failed','NETWORK_ERROR');}if (error.message?.includes('PostConditionFailed')) {throw new TransactionError('Transaction safety check failed','POST_CONDITION_FAILED',error.txId,error.postConditions);}// Unknown errorthrow new TransactionError('Transaction failed','UNKNOWN_ERROR',error.txId,error);}}
Audit checklist
Security checklist for your application:
interface SecurityAudit {inputValidation: boolean;postConditions: boolean;errorHandling: boolean;keyManagement: boolean;networkSecurity: boolean;reentrancyProtection: boolean;frontRunningProtection: boolean;}function auditTransaction(txOptions: any): SecurityAudit {return {inputValidation: !!(txOptions.recipient &&validateStacksAddress(txOptions.recipient)),postConditions: !!(txOptions.postConditions &&txOptions.postConditions.length > 0),errorHandling: !!(txOptions.onError ||txOptions.catch),keyManagement: !!(!txOptions.senderKey ||txOptions.senderKey.startsWith('$')),networkSecurity: !!(txOptions.network &&txOptions.network.coreApiUrl.startsWith('https')),reentrancyProtection: !!(txOptions.nonce !== undefined),frontRunningProtection: !!(txOptions.postConditionMode === PostConditionMode.Deny),};}
Security monitoring
Monitor your application for suspicious activity:
class SecurityMonitor {private alerts: Alert[] = [];async monitorTransaction(txId: string) {const tx = await this.fetchTransaction(txId);// Check for unusual patternsif (this.isHighValue(tx)) {this.alert('HIGH_VALUE_TX', { txId, amount: tx.amount });}if (this.isRapidSequence(tx)) {this.alert('RAPID_TX_SEQUENCE', { txId, sender: tx.sender });}if (this.isUnknownContract(tx)) {this.alert('UNKNOWN_CONTRACT', {txId,contract: tx.contractAddress});}}private alert(type: string, data: any) {const alert = {type,timestamp: Date.now(),data,};this.alerts.push(alert);console.warn('Security Alert:', alert);// Send to monitoring servicethis.sendToMonitoring(alert);}}
Best practices summary
- 1Always use post-conditions - They're your safety net
- 2Validate all inputs - Never trust user data
- 3Handle errors gracefully - Expect and plan for failures
- 4Secure key management - Never expose private keys
- 5Monitor transactions - Detect issues early
- 6Test security scenarios - Include attack vectors in tests
- 7Keep dependencies updated - Security patches matter