“Rust prevents you from shooting yourself in the foot with memory corruption, but it can’t stop you from aiming the gun at your users’ money.” - The Security Rustacean’s Creed
Rust gives you memory safety for free, but application security is earned through discipline. This guide will teach you to think like both a Rustacean and a security engineer, because in production systems, being “mostly secure” is like being “mostly pregnant.”
The Security Trinity:
Every u64
in your API is a potential million-dollar bug waiting to happen:
// ⚠️ Three bare u64s – compiler can’t tell them apart
fn transfer(from: u64, to: u64, amount: u64) -> Result<(), Error> {
// What happens when a tired developer swaps these at 2 AM?
// transfer(balance, user_id, amount) ← 💥 Goodbye money
}
In traditional languages, this compiles and runs. In blockchain contexts, it transfers user ID 12345
tokens from account 67890
. The code works perfectly—it just transfers money to the wrong place.
Wrap every meaningful primitive in a semantic type:
// ✅ Type-safe: cross-type swaps won’t compile
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct UserId(u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct Balance(u64);
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
struct TokenAmount(u64);
// Cross-type swaps now fail to compile
fn transfer(from: UserId, to: UserId, amount: TokenAmount) -> Result<(), Error> {
// Swapping two UserId values still compiles — keep names clear
}
The Magic: These wrappers vanish at compile time (zero runtime cost) while preventing cross-type parameter swaps.
// ❌ PRODUCTION INCIDENT: All roots look the same
fn verify_proof(root: [u8; 32], proof: Vec<[u8; 32]>, leaf: [u8; 32]) -> bool {
// merkle verification logic
}
// Oops - passed balance root where nullifier root expected
let valid = verify_proof(balance_root, proof, nullifier_hash); // 💥 Logic bug
// ✅ BULLETPROOF: Each root type is distinct
struct BalanceRoot([u8; 32]);
struct NullifierRoot([u8; 32]);
fn verify_balance_proof(root: BalanceRoot, proof: Vec<[u8; 32]>, leaf: [u8; 32]) -> bool {
// verification logic
}
// This won't compile - the type system saves us!
let valid = verify_balance_proof(nullifier_root, proof, leaf); // ❌ Compile error
When to Use Newtypes (Answer: Almost Always):
In Web3 and financial systems, panics aren’t just crashes—they’re denial-of-service attacks:
// ❌ DOS VULNERABILITY
fn calculate_fee(amount: u64, rate: u64) -> u64 {
amount.checked_mul(rate).unwrap() / 10000 // 💥 Panic = burned gas + no tx
}
An attacker provides values that cause overflow, the transaction panics, the user’s gas is burned, and nothing happens. Repeat this attack to effectively DoS a smart contract.
// ✅ GRACEFUL FAILURE
fn calculate_fee_safe(amount: u64, rate: u64) -> Result<u64, FeeError> {
let fee_total = amount
.checked_mul(rate)
.ok_or(FeeError::Overflow)?; // Returns error instead of panic
Ok(fee_total / 10000)
}
The ? Operator Magic:
Ok(value)
→ extracts value and continuesErr(error)
→ returns early with errorSometimes unwrap() is mathematically provable to be safe:
// ✅ SAFE: Vector was just created with known size
let numbers = vec![1, 2, 3, 4, 5];
let first = numbers.get(0).expect("vector has 5 elements");
// ✅ SAFE: We just checked the condition
if !user_input.is_empty() {
let first_char = user_input.chars().next().unwrap();
}
Rule: If you can’t write a comment explaining why the unwrap()
can’t fail, it probably can.
Rust silently wraps overflows in release mode by default, turning your financial calculations into random number generators:
// ❌ SILENT MONEY CORRUPTION
fn add_to_balance(current: u64, deposit: u64) -> u64 {
current + deposit // If this overflows: u64::MAX + 1 = 0
}
// User has maximum balance, deposits 1 wei → balance becomes 0!
This isn’t theoretical. Integer overflow has caused real financial losses in production systems.
// ✅ OVERFLOW DETECTION
fn add_to_balance_safe(current: u64, deposit: u64) -> Result<u64, BalanceError> {
current
.checked_add(deposit)
.ok_or(BalanceError::Overflow)
}
Use for: Money, prices, balances, critical calculations
// ✅ CLAMPING TO BOUNDS
fn apply_penalty(reputation: u32, penalty: u32) -> u32 {
reputation.saturating_sub(penalty) // Never goes below 0
}
fn increment_counter(count: u32) -> u32 {
count.saturating_add(1) // Never overflows, just stays at MAX
}
Use for: Counters, reputation systems, rate limiting
// ✅ EXPLICIT WRAPAROUND
fn hash_combine(hash: u32, value: u32) -> u32 {
hash.wrapping_mul(31).wrapping_add(value) // Wraparound is expected
}
Use for: Cryptographic operations where wraparound is mathematically correct
// ❌ WRONG: Float precision and rounding issues
fn calculate_fee_wrong(amount: u64, rate_percent: f64) -> u64 {
(amount as f64 * rate_percent / 100.0).round() as u64 // 💥 Precision loss
}
// ✅ CORRECT: Integer arithmetic with explicit rounding
fn calculate_fee_correct(amount: u64, rate_bps: u64) -> Result<u64, Error> {
// Multiply first, then divide (order matters!)
let fee_precise = amount
.checked_mul(rate_bps)
.ok_or(Error::Overflow)?;
// For fees: round UP (ceiling division)
let fee = fee_precise / 10000;
if fee_precise % 10000 > 0 {
fee.checked_add(1).ok_or(Error::Overflow)
} else {
Ok(fee)
}
}
fn calculate_payout(amount: u64, rate_bps: u64) -> Result<u64, Error> {
// For payouts: round DOWN (floor division)
amount
.checked_mul(rate_bps)
.and_then(|x| x.checked_div(10000))
.ok_or(Error::Overflow)
}
Golden Rules:
[profile.release]
overflow-checks = true # Panic instead of silent wraparound
Cryptographic security often reduces to: “Can an attacker predict this number?”
// ❌ PREDICTABLE = BROKEN
use rand::{Rng, rngs::StdRng, SeedableRng};
let mut rng = StdRng::seed_from_u64(42); // Same seed = same sequence!
let private_key: [u8; 32] = rng.gen(); // Predictable = stolen funds
// ✅ CRYPTOGRAPHICALLY SECURE
use rand::rngs::OsRng;
let private_key: [u8; 32] = OsRng.gen(); // Pulls from OS entropy pool
Rule: If it protects secrets or money, use OsRng
. If it’s for gameplay or testing, deterministic is fine.
Secrets don’t just disappear when you think they do:
// ❌ SECRET LIVES FOREVER IN MEMORY
let mut password = String::from("super_secret_password");
password.clear(); // Only changes length - data remains in RAM!
// ✅ CRYPTOGRAPHICALLY SECURE WIPING
use zeroize::{Zeroize, Zeroizing};
// Manual zeroization
let mut secret = [0u8; 32];
OsRng.fill_bytes(&mut secret);
// ... use secret ...
secret.zeroize(); // Overwrites memory with zeros
// Automatic zeroization
let api_key = Zeroizing::new(load_api_key());
// Automatically zeroized when dropped
// ❌ SECRETS IN LOGS
#[derive(Debug)]
struct ApiCredentials {
key: String,
secret: String,
}
let creds = ApiCredentials { /* ... */ };
println!("Credentials: {:?}", creds); // Logged forever!
// ✅ REDACTED LOGGING
use secrecy::{Secret, ExposeSecret};
struct ApiCredentials {
key: String,
secret: Secret<String>,
}
let creds = ApiCredentials {
key: "public_key".to_string(),
secret: Secret::new("very_secret".to_string()),
};
println!("Credentials: key={}, secret=[REDACTED]", creds.key);
// Only expose when absolutely necessary
let actual_secret = creds.secret.expose_secret();
// ✅ AUTHENTICATED ENCRYPTION (never use raw encryption!)
use aes_gcm::{Aes256Gcm, Key, Nonce, aead::{Aead, NewAead}};
fn encrypt_secure(plaintext: &[u8], key: &[u8; 32]) -> Result<Vec<u8>, Error> {
let cipher = Aes256Gcm::new(Key::from_slice(key));
let nonce: [u8; 12] = OsRng.gen();
let ciphertext = cipher
.encrypt(Nonce::from_slice(&nonce), plaintext)
.map_err(|_| Error::EncryptionFailed)?;
// Prepend nonce for storage
let mut result = nonce.to_vec();
result.extend_from_slice(&ciphertext);
Ok(result)
}
// ✅ CONSTANT-TIME COMPARISONS (prevent timing attacks)
use subtle::ConstantTimeEq;
fn verify_mac(expected: &[u8], actual: &[u8]) -> bool {
expected.ct_eq(actual).into() // Always takes same time
}
Even in Rust, string formatting creates injection vulnerabilities:
// ❌ SQL INJECTION
fn find_user(name: &str) -> Result<User, Error> {
let query = format!("SELECT * FROM users WHERE name = '{}'", name);
// name = "'; DROP TABLE users; --" = goodbye database
database.execute(&query)
}
// ✅ PARAMETERIZED QUERIES
use sqlx::PgPool;
async fn find_user_safe(pool: &PgPool, name: &str) -> Result<User, Error> {
let user = sqlx::query_as!(
User,
"SELECT * FROM users WHERE name = $1", // $1 is safely escaped
name
)
.fetch_one(pool)
.await?;
Ok(user)
}
// ❌ COMMAND INJECTION
fn search_logs(pattern: &str) -> Result<String, Error> {
let output = Command::new("sh")
.arg("-c")
.arg(format!("grep {} /var/log/app.log", pattern)) // pattern = "; rm -rf /"
.output()?;
Ok(String::from_utf8(output.stdout)?)
}
// ✅ SAFE: Individual arguments, no shell
fn search_logs_safe(pattern: &str) -> Result<String, Error> {
let output = Command::new("grep")
.arg(pattern) // Treated as literal argument
.arg("/var/log/app.log")
.output()?;
Ok(String::from_utf8(output.stdout)?)
}
Async Rust is fast until you accidentally block the entire runtime:
// ❌ BLOCKS ENTIRE RUNTIME
async fn hash_password_wrong(password: &str) -> String {
// This CPU-intensive work freezes ALL async tasks!
expensive_password_hash(password)
}
// ✅ OFFLOAD TO THREAD POOL
async fn hash_password_right(password: String) -> Result<String, Error> {
let hash = tokio::task::spawn_blocking(move || {
expensive_password_hash(&password)
})
.await
.map_err(|_| Error::TaskFailed)?;
Ok(hash)
}
When to use spawn_blocking
:
// ❌ DEADLOCK WAITING TO HAPPEN
async fn dangerous_pattern(shared: &Mutex<Vec<String>>) {
let mut data = shared.lock().unwrap(); // Lock acquired
data.push("item".to_string());
some_async_operation().await; // ⚠️ Lock held across await!
data.push("another".to_string());
} // Lock released only here - other tasks blocked!
// ✅ SAFE: Release locks before await points
async fn safe_pattern(shared: &tokio::sync::Mutex<Vec<String>>) {
{
let mut data = shared.lock().await;
data.push("item".to_string());
} // Lock released here
some_async_operation().await; // No lock held
{
let mut data = shared.lock().await;
data.push("another".to_string());
} // Lock released again
}
Every .await
is a potential cancellation point where your future might be dropped:
// ❌ NOT CANCELLATION SAFE
async fn transfer_funds_unsafe(from: &Account, to: &Account, amount: u64) {
from.balance -= amount; // ⚠️ What if cancelled here?
network_commit().await; // Cancellation point!
to.balance += amount; // Might never execute → money lost!
}
// ✅ CANCELLATION SAFE: Atomic state updates
async fn transfer_funds_safe(from: &Account, to: &Account, amount: u64) -> Result<(), Error> {
// Do all async work first
let transfer_id = prepare_transfer(amount).await?;
// Then atomic state update (no cancellation points)
tokio::task::spawn_blocking(move || {
// This runs to completion
from.balance -= amount;
to.balance += amount;
commit_transfer(transfer_id);
}).await?;
Ok(())
}
// ❌ AUTHORIZATION BYPASS
pub fn withdraw(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let user_account = &mut ctx.accounts.user_account;
// Bug: Never verified that user_account signed this transaction!
user_account.balance -= amount;
Ok(())
}
// ✅ VERIFY AUTHORIZATION
pub fn withdraw_safe(ctx: Context<Withdraw>, amount: u64) -> Result<()> {
let user_account = &mut ctx.accounts.user_account;
// Critical: Verify the account holder authorized this
require!(user_account.is_signer, ErrorCode::MissingSigner);
// Additional safety: Check balance
require!(
user_account.balance >= amount,
ErrorCode::InsufficientFunds
);
user_account.balance -= amount;
Ok(())
}
// ❌ TRUSTING USER INPUT
pub fn update_vault(ctx: Context<UpdateVault>) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// Bug: Attacker could pass a vault they control!
vault.amount += 100;
Ok(())
}
// ✅ VERIFY PDA OWNERSHIP
pub fn update_vault_safe(ctx: Context<UpdateVault>) -> Result<()> {
let vault = &mut ctx.accounts.vault;
// Recompute expected PDA
let (expected_vault, _bump) = Pubkey::find_program_address(
&[b"vault", ctx.accounts.user.key().as_ref()],
ctx.program_id,
);
require!(vault.key() == expected_vault, ErrorCode::InvalidVault);
vault.amount += 100;
Ok(())
}
// ❌ NON-DETERMINISTIC (causes consensus failures)
pub fn create_auction(duration_hours: u64) -> Result<()> {
let start_time = SystemTime::now() // Different on each validator!
.duration_since(UNIX_EPOCH)
.unwrap()
.as_secs();
Ok(())
}
// ✅ DETERMINISTIC
pub fn create_auction_safe(ctx: Context<CreateAuction>, duration_hours: u64) -> Result<()> {
let clock = Clock::get()?; // Blockchain-provided timestamp
let start_time = clock.unix_timestamp as u64; // Same on all validators
Ok(())
}
Every unsafe
block must explain its safety invariants:
// ❌ DANGEROUS: No safety documentation
unsafe fn write_bytes(ptr: *mut u8, bytes: &[u8]) {
std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr, bytes.len());
}
// ✅ SAFE: Clear safety contract
/// Copy bytes to the given pointer.
///
/// # Safety
///
/// - `ptr` must be non-null and properly aligned
/// - `ptr` must point to writable memory for at least `bytes.len()` bytes
/// - The memory region must not overlap with `bytes`
/// - No other threads may access the memory region during this call
unsafe fn write_bytes_safe(ptr: *mut u8, bytes: &[u8]) {
debug_assert!(!ptr.is_null(), "ptr must not be null");
debug_assert!(ptr.is_aligned(), "ptr must be aligned");
// SAFETY: Caller guarantees all safety requirements above
std::ptr::copy_nonoverlapping(bytes.as_ptr(), ptr, bytes.len());
}
extern "C" {
fn c_hash_function(input: *const u8, len: usize, output: *mut u8);
}
// ✅ SAFE FFI WRAPPER
/// Compute hash using C library.
pub fn hash_bytes(data: &[u8]) -> [u8; 32] {
let mut output = [0u8; 32];
// SAFETY:
// - data.as_ptr() is valid for data.len() bytes
// - output.as_mut_ptr() is valid for 32 bytes
// - C function documented to write exactly 32 bytes
unsafe {
c_hash_function(data.as_ptr(), data.len(), output.as_mut_ptr());
}
output
}
# ✅ Security audit pipeline
cargo audit # Check for vulnerabilities
cargo build --release --locked # Use exact dependency versions
cargo clippy -- -D warnings # Lint for security issues
[profile.release]
overflow-checks = true # Catch integer overflows
debug-assertions = true # Keep debug_assert! in release
strip = true # Remove debug symbols
lto = true # Link-time optimization
codegen-units = 1 # Better optimization
# Security-focused clippy config
[alias]
secure-check = [
"clippy", "--all-targets", "--all-features", "--",
"-D", "clippy::unwrap_used",
"-D", "clippy::expect_used",
"-D", "clippy::indexing_slicing",
"-D", "clippy::panic",
]
use proptest::prelude::*;
// Test security properties, not just happy paths
proptest! {
#[test]
fn transfer_never_creates_money(
initial_from in 0u64..=1_000_000,
initial_to in 0u64..=1_000_000,
amount in 0u64..=1_000_000
) {
let mut from = Account { balance: initial_from };
let mut to = Account { balance: initial_to };
let total_before = initial_from + initial_to;
let _ = transfer(&mut from, &mut to, amount);
let total_after = from.balance + to.balance;
prop_assert_eq!(total_before, total_after, "Money created or destroyed!");
}
}
For every function, ask:
// ✅ LAYERED SECURITY
pub fn process_payment(
user: &User,
amount: TokenAmount,
signature: &Signature,
) -> Result<(), PaymentError> {
// Layer 1: Authentication
verify_signature(&user.public_key, signature)?;
// Layer 2: Authorization
user.check_payment_permissions()?;
// Layer 3: Input validation
if amount.0 == 0 {
return Err(PaymentError::ZeroAmount);
}
// Layer 4: Business rules
if amount.0 > user.balance.0 {
return Err(PaymentError::InsufficientFunds);
}
// Layer 5: Rate limiting
user.check_rate_limit()?;
// Layer 6: Overflow protection
let new_balance = user.balance.0
.checked_sub(amount.0)
.ok_or(PaymentError::ArithmeticError)?;
// Finally: Execute
user.balance = Balance(new_balance);
user.record_payment(amount);
Ok(())
}
Before deploying Rust code to production:
unwrap()
or panic!()
in production pathsResult
types properly handled with ?
checked_*
OsRng
for all security-critical randomnessZeroizing
or secrecy
Debug
output or logsformat!()
+ Command.await
pointsSystemTime::now()
)unsafe
blocks documented with safety contractscargo audit
passes--locked
flagResult
types“Memory safety for free, application security for a price—but that price is just good habits.”
Remember: Rust gives you a head start on security, but it’s not a silver bullet. The most secure code is code that’s never written, and the second most secure code is code that’s written by developers who think like attackers.
Now go forth and build systems that are not just fast and memory-safe, but actually secure. Your users’ money depends on it. 🦀🔒💰