Rust + AWS Lambda: Secure Credential-Free Access with Workload Credentials

How Does Your Lambda Prove Who It Is?
Every serverless developer eventually faces this question: your Lambda needs to read a file from S3 — what gives it the right to do that?
The most common answer is “I put the Access Key in environment variables.” It looks fast, but wait — if that key leaks, your entire AWS account is wide open. Access keys are long-lived. They don’t rotate. And you have to remember to revoke them.
An even worse practice is baking credentials into application code. I’ve seen projects that accidentally committed their .env file to GitHub — the AWS billing alarm went off within 30 minutes. If you’re still doing this, go run a secret scan right now.
The correct approach is: don’t let your code hold long-lived credentials. Let the Lambda runtime environment hand out temporary credentials automatically. The AWS SDK has a built-in mechanism for this — the Default Credential Provider Chain. When your code runs on Lambda, the SDK locates the execution environment’s temporary credentials on its own. The developer doesn’t write a single line of credential management code.
This post walks through building such a Lambda in Rust — from IAM role creation to deployment to verification — no shortcuts.
What’s Wrong With the Old Ways?
Three common pitfalls:
Hardcoded credentials. AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY written into code or config files. Once leaked, they’re valid until manually revoked. GitHub’s secret scanning consistently ranks this type of leak in the top three findings.
Environment variables for secrets. Slightly better — not committed to the repo — but fundamentally the same problem: static, long-lived keys. If the Lambda is compromised through another vector, the attacker can read the environment variables and walk away with valid credentials.
Overly permissive IAM roles. Attaching AdministratorAccess or “S3 FullAccess” to a Lambda for convenience. Sure, it’s easier, but why should a Lambda that only reads one S3 prefix be able to delete entire buckets? This is how attackers move laterally within an AWS account.
The ideal solution is simple: minimum permissions + automatic rotation + zero credential code in the application. The AWS SDK’s Workload Credentials Provider chain delivers all three.
How the Default Credential Provider Chain Works
When you call aws_config::load_from_env(), the SDK internally tries credential sources in this order:
- Environment variables —
AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY - AWS config files —
~/.aws/credentialsor~/.aws/config - Web Identity Token — EKS / ECS OIDC scenarios
- ECS container credentials —
AWS_CONTAINER_CREDENTIALS_RELATIVE_URI - EC2 Instance Metadata Service (IMDS) — EC2 instance profiles
- Lambda runtime credentials — via the
AWS_LAMBDA_RUNTIME_APIenvironment variable
When your code runs on Lambda, item 6 is hit. The Lambda runtime obtains temporary credentials (AWS_ACCESS_KEY_ID + AWS_SECRET_ACCESS_KEY + AWS_SESSION_TOKEN) through sts:AssumeRole beforehand, and exposes them via a local HTTP endpoint. The SDK automatically requests, refresh, and renews them before expiry — transparent to the developer.
So your Rust code only needs to do one thing: create an SDK client. No credentials to pass. The Lambda execution role handles the rest.
Step 1: Create the IAM Role
Your Lambda needs a role to tell AWS what it’s allowed to do. Let’s create one that only permits s3:ListAllMyBuckets.
Create a trust policy file lambda-trust-policy.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Principal": {
"Service": "lambda.amazonaws.com"
},
"Action": "sts:AssumeRole"
}
]
}
Create a permissions policy file s3-list-policy.json:
{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": [
"s3:ListAllMyBuckets"
],
"Resource": "*"
}
]
}
Then create the role and attach the policy:
aws iam create-role \
--role-name RustLambdaS3ListerRole \
--assume-role-policy-document file://lambda-trust-policy.json
aws iam put-role-policy \
--role-name RustLambdaS3ListerRole \
--policy-name S3ListAllMyBucketsPolicy \
--policy-document file://s3-list-policy.json
Save the role ARN — you’ll need it later:
aws iam get-role --role-name RustLambdaS3ListerRole --query 'Role.Arn' --output text
Step 2: Write the Rust Lambda
Prerequisites
- Rust toolchain (latest stable recommended, minimum 1.91.1)
cargo-lambdainstalled
# macOS / Linux
curl -fsSL https://cargo-lambda.info/install.sh | sh
Initialize the project
cargo lambda new rust-s3-lister --runtime rust
cd rust-s3-lister
Cargo.toml
As of June 2026, these versions are current. Always check crates.io for the latest before starting a new project.
[dependencies]
lambda_runtime = "1.2.1"
tokio = { version = "1", features = ["macros"] }
tracing = { version = "0.1", features = ["log"] }
tracing-subscriber = { version = "0.3", features = ["fmt"] }
serde_json = "1.0"
# AWS SDK for Rust
aws-config = { version = "1.1.7", features = ["behavior-version-latest"] }
aws-sdk-s3 = "1.134.0"
main.rs
The key code is just a few lines. No manual environment variable reads. No explicit STS calls. One aws_config::load_from_env() call handles everything.
use lambda_runtime::{service_fn, LambdaEvent, Error};
use serde_json::{json, Value};
use aws_config::BehaviorVersion;
use aws_sdk_s3::Client;
#[tokio::main]
async fn main() -> Result<(), Error> {
tracing_subscriber::fmt()
.with_max_level(tracing::Level::INFO)
.without_time()
.init();
// This single line is all it takes — the SDK automatically picks up
// temporary credentials from the Lambda runtime environment
let config = aws_config::load_from_env(BehaviorVersion::latest()).await;
let s3_client = Client::new(&config);
lambda_runtime::run(service_fn(|event: LambdaEvent<Value>| {
function_handler(event, &s3_client)
}))
.await?;
Ok(())
}
async fn function_handler(event: LambdaEvent<Value>, s3_client: &Client) -> Result<Value, Error> {
tracing::info!("Received event: {:?}", event);
let response = s3_client.list_buckets().send().await;
match response {
Ok(output) => {
let buckets: Vec<String> = output
.buckets()
.unwrap_or_default()
.iter()
.filter_map(|b| b.name().map(String::from))
.collect();
tracing::info!("Successfully listed S3 buckets: {:?}", buckets);
Ok(json!({
"message": format!("Found {} buckets: {:?}", buckets.len(), buckets)
}))
}
Err(e) => {
tracing::error!("Failed to list S3 buckets: {:?}", e);
Err(format!("Error listing S3 buckets: {}", e).into())
}
}
}
Why BehaviorVersion::latest()?
The aws-config crate has a BehaviorVersion mechanism. When the SDK introduces breaking changes (a default behavior shifts), this parameter lets your application declare which version it expects. latest() means “follow the newest version.” For production, pin to a specific version after validation in your staging environment.
No panics
list_buckets() returns Result; handled here with a match. If you prefer a “continue on success, bail on failure” pattern, consider anyhow or color-eyre instead of Box<dyn Error>, but never use unwrap(). Lambda panic messages aren’t well-structured in CloudWatch Logs — tracing::error is much cleaner.
Step 3: Deploy
Build
Graviton (ARM64) offers better price-performance on Lambda — comparable throughput at 20% lower cost. Build for ARM64:
cargo lambda build --release --target aarch64-unknown-linux-musl
If you’re on an x86_64 machine cross-compiling to ARM, cargo lambda build uses Zig for cross-compilation automatically — no additional configuration needed.
Deploy
cargo lambda deploy rust-s3-lister \
--role arn:aws:iam::YOUR_AWS_ACCOUNT_ID:role/RustLambdaS3ListerRole \
--region us-east-1 \
--arm64
Replace YOUR_AWS_ACCOUNT_ID with your actual account ID, and adjust the region.
cargo lambda deploy does three things:
- Packages the build artifact into a ZIP (or container image, depending on your runtime choice)
- Calls
CreateFunctionorUpdateFunctionCodeto upload to Lambda - Sets the function execution role via the
--roleparameter
To update an existing function’s code:
cargo lambda deploy rust-s3-lister
Omitting --role and --region reuses the previously deployed configuration.
Step 4: Verify
Invoke the Lambda via the AWS CLI:
aws lambda invoke \
--function-name rust-s3-lister \
--payload '{}' \
response.json
cat response.json
Expected output:
{
"message": "Found 12 buckets: [\"my-first-bucket\", \"another-project-bucket\", ...]"
}
Then check CloudWatch Logs:
INFO Received event: LambdaEvent { ... }
INFO Successfully listed S3 buckets: ["my-first-bucket", "another-project-bucket"]
If you see “Access Denied”, check two things:
- Whether the IAM role correctly has the
s3:ListAllMyBucketspolicy attached - Whether the trust relationship lists
lambda.amazonaws.comas the Principal
Going Deeper
Cold Start and Runtime Choice
Rust on Lambda has been extensively benchmarked. As of mid-2026, on ARM64 the median cold start time (fully cold, including SDK initialization) is around 20ms. x86_64 is slightly higher but still under 30ms. Compare with Python’s 100-200ms or Node.js’s 80-150ms.
The reasons are straightforward:
- Rust is natively compiled — no JIT warmup needed
- SDK initialization is lightweight — no module downloading or parsing
aws_config::load_from_env()makes a single HTTP request to the local Lambda runtime endpoint (127.0.0.1) — negligible latency
AWS officially GA’d Rust Lambda runtime support in November 2025, and the ecosystem has been maturing steadily since. If you want lowest latency, Rust + ARM64 is the current optimal combination on Lambda.
Lambda Managed Instances for Concurrency
In March 2026, Lambda Managed Instances added Rust support. This allows a single execution environment to handle multiple requests concurrently — a huge win for reducing cold starts in high-throughput scenarios. If your Lambda handles significant traffic, check out lambda_runtime’s run_concurrent() API. The idea: multiple Tokio tasks share one execution environment, reusing SDK connection pools and cached data.
Cross-Account Scenarios
This post covers single-account Lambda access. For cross-account access, the typical pattern is to create an IAM role in the target account and manually call sts:AssumeRole from within the Lambda. The aws-sts crate supports this. Cross-account has more gotchas (trust policies, external IDs, permission boundaries) — beyond this post’s scope.
FAQs
Q: Why not pin a specific BehaviorVersion like v2024_11_15()?
latest() is fine for this demo. Production deployments should pin to a validated version to avoid default behavior changes from SDK updates causing issues. Validate in staging before pinning.
Q: How do I test locally?
The SDK credential chain picks up your local ~/.aws/credentials (items 1 or 2 in the chain). Use the AWS_PROFILE environment variable to switch between different account configurations. See our article on LocalStack testing
for local integration testing.
Q: What does cargo-lambda do for me vs. manual packaging?
It wraps the compile → package → upload pipeline. Manually you’d need to strip debug symbols, static-link with musl-gcc, then hand-assemble a ZIP and upload it. cargo-lambda handles all of this and includes Zig for cross-platform cross-compilation. Use it unless you have custom runtime requirements.
Q: When is Rust NOT the right choice for Lambda?
- Your team doesn’t know Rust and can’t maintain the code
- The logic is trivially simple — 50 lines of Python/Node would suffice, and the build/deploy complexity of Rust isn’t justified
- You depend heavily on C libraries without Rust bindings, making the deployment package balloon (Lambda limit: 50MB compressed / 250MB uncompressed)
Q: What if my Lambda needs multiple AWS services (DynamoDB, SQS, SES)?
Just add the dependencies. AWS SDK for Rust uses tree-shaped dependency resolution — you only include the aws-sdk-* crates you actually use. Multiple clients share a single SdkConfig, so credential fetching happens once.
Interested in Rust for cloud-native development? Follow Dream Beast Programming for weekly in-depth Rust content.
Also check out Rust Production Logging Practices for more Rust serverless experience.
