Keeping API keys and webhook signing keys out of an Actix Web 4 codebase with Encore, environment variables with dotenvy and secrecy, or AWS Secrets Manager.
An Actix Web app collects secrets quickly: a payment provider key, a webhook signing key, a database password. Each one needs a value per environment, a way into the process that does not pass through git, and code that never prints it. Rotating a key should not need a code change.
This guide adds secrets to an Actix Web 4 app with Encore, dotenvy with secrecy, and AWS Secrets Manager. The example verifies a signed payment webhook and reports whether the payment key is a test or live key, without revealing either.
What you need to decide:
Encore combines an open source infrastructure SDK for TypeScript, Go, Python and Rust with a platform that deploys into your own AWS or GCP account. The Rust SDK lets an Actix Web app declare its secrets as statics. A SharedSecret has one value for the whole app, and a Secret belongs to one service. Values come from .secrets.user.toml locally and from your cloud's secret manager when deployed.
The example uses Actix Web 4 with the hmac, sha2 and hex crates:
use actix_web::{get, post, web, App, HttpRequest, HttpResponse, HttpServer};
use encore::secrets::{Secret, SharedSecret};
use hmac::{Hmac, KeyInit, Mac};
use sha2::Sha256;
// One value for the whole app, granted to each service that reads it.
static STRIPE_KEY: SharedSecret = SharedSecret::new("StripeKey");
// Belongs to this service only, stored as raw bytes.
static WEBHOOK_SIGNING_KEY: Secret<Vec<u8>> = Secret::new("WebhookSigningKey");
#[post("/webhooks/payments")]
async fn payment_webhook(req: HttpRequest, body: web::Bytes) -> HttpResponse {
// Take the value where it is used, so a rotated key is picked up.
let key = match WEBHOOK_SIGNING_KEY.value() {
Ok(key) => key,
Err(_) => return HttpResponse::InternalServerError().finish(),
};
let signature = req
.headers()
.get("X-Signature")
.and_then(|v| v.to_str().ok())
.and_then(|v| hex::decode(v).ok());
let Some(signature) = signature else {
return HttpResponse::Unauthorized().finish();
};
let mut mac = Hmac::<Sha256>::new_from_slice(&key).expect("HMAC accepts any key length");
mac.update(&body);
// verify_slice compares in constant time.
if mac.verify_slice(&signature).is_err() {
return HttpResponse::Unauthorized().finish();
}
HttpResponse::Ok().json(serde_json::json!({ "accepted": true }))
}
#[get("/billing/mode")]
async fn billing_mode() -> HttpResponse {
match STRIPE_KEY.value() {
// Report a fact about the key, never the key itself.
Ok(key) => {
let mode = if key.starts_with("sk_live_") { "live" } else { "test" };
HttpResponse::Ok().json(serde_json::json!({ "mode": mode }))
}
Err(_) => HttpResponse::InternalServerError().finish(),
}
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
encore::initialize();
let addr = std::env::var("ENCORE_LISTEN_ADDR").unwrap_or("0.0.0.0:8080".into());
HttpServer::new(|| App::new().service(payment_webhook).service(billing_mode))
.bind(addr)?
.run()
.await
}
The handler takes the body as web::Bytes, because the signature covers the exact bytes the provider sent. Local values go in .secrets.user.toml, which stays out of git:
[shared]
StripeKey = "sk_test_51abc"
[services.payments]
WebhookSigningKey = "whsec_local_123"
A correct signature is accepted and a wrong or missing one gets 401:
On deploy, the values are stored in AWS Secrets Manager or GCP Secret Manager in your own account, and Encore provisions an IAM role for the service. Production, staging and preview environments each get their own values, so a pull request never sees the live key.
Secret values are not stored in the codebase or in a .env file. Shared values live in Encore's secrets manager and, once deployed, in your cloud's secret manager, so an AI coding agent working in the repository sees which secrets a service declares but never sees the values in the files it works with, and encore secret list never prints a value. The exception is a .secrets.user.toml override, a plaintext file in the app root that Encore adds to .gitignore, which keeps it out of commits but not out of reach of an agent reading the project.
The common self-managed setup reads environment variables at startup, with dotenvy loading a .env file in development and secrecy wrapping the values so Debug prints a placeholder:
use secrecy::{ExposeSecret, SecretString};
#[derive(Clone)]
struct Secrets {
webhook_signing_key: SecretString,
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
dotenvy::dotenv().ok(); // no-op when there is no .env file
let secrets = Secrets {
webhook_signing_key: std::env::var("WEBHOOK_SIGNING_KEY")
.expect("WEBHOOK_SIGNING_KEY is not set")
.into(),
};
HttpServer::new(move || App::new().app_data(web::Data::new(secrets.clone())).service(payment_webhook))
.bind("0.0.0.0:8080")?
.run()
.await
}
// In the handler:
// Hmac::<Sha256>::new_from_slice(secrets.webhook_signing_key.expose_secret().as_bytes())
A .env file sits in the project directory, where any AI coding agent or tool with access to the repository can read it, and that is a common way keys end up in prompts, logs or commits.
Failing at startup when a variable is missing is better than failing on the first webhook. The values still have to reach production somehow, usually through the task definition or a platform setting, and rotating one means restarting the app.
To read secrets directly, create a client with aws-config and fetch the value at startup or with a short cache:
use aws_config::BehaviorVersion;
let config = aws_config::load_defaults(BehaviorVersion::latest()).await;
let client = aws_sdk_secretsmanager::Client::new(&config);
let out = client
.get_secret_value()
.secret_id("payments/webhook-signing-key")
.send()
.await
.expect("could not read the signing key");
let key = out.secret_string().unwrap_or_default().to_owned();
The task role needs secretsmanager:GetSecretValue on that secret, and development needs either AWS credentials or a fallback. Fetching on every request adds latency and cost, so cache the value with a refresh interval that matches your rotation.
Pick Encore when you want secrets declared next to the Actix handlers that use them, local values in one ignored file, and per-environment values in your cloud's secret manager. Pick dotenvy with secrecy for a single deployment where environment variables are already managed well. Pick Secrets Manager directly when you need rotation and audit logs and are ready to write the IAM and caching.
You can sign up for early access to Encore for Rust to try it with your Actix Web app.
Keep them out of the code and the repository, load them at startup or on use, and wrap them so they are not printed by Debug. With Encore, declare static WEBHOOK_SIGNING_KEY: Secret<Vec<u8>> = Secret::new("WebhookSigningKey"), set it with encore secret set per environment, and read it with .value() in the handler.
Take the raw body as web::Bytes, compute an HMAC-SHA256 with the signing key using the hmac and sha2 crates, and compare with verify_slice, which runs in constant time. Parsing the JSON first can change the bytes and break the signature.
For local development, if it is in .gitignore. In production, anyone who can inspect the process or task definition can read environment variables. A secret manager with access control and audit logs is the safer source.
Deploy to your cloud on AWS and Google Cloud
without managing infrastructure
Encore lets developers and agents define application infrastructure directly in code, then automatically provisions it from local development to production in your AWS or GCP account.