Managing API keys, webhook signing keys and JSON credentials in an Axum 0.8 app with Encore, dotenvy with secrecy, AWS Secrets Manager or Google Secret Manager.
Axum services pick up secrets as they grow, such as a payment API key, a webhook signing key and SMTP credentials. In Rust they usually arrive as environment variables read at startup, which leaves storage, per-environment values and keeping them out of logs to the team.
This guide adds secrets to an Axum 0.8 billing service with Encore, dotenvy with secrecy, AWS Secrets Manager and Google Secret Manager. The examples read a payment key, a service-only signing key and SMTP credentials stored as JSON.
What you need to decide:
Encore combines secrets declared in your Axum code with storage per environment, reading local values from a git-ignored file under encore run and keeping production values in AWS Secrets Manager or GCP Secret Manager in your own account. It is an infrastructure SDK, the encore-dev crate, added to an existing app, and the router and handlers stay plain Axum 0.8.
Secrets are statics. A SharedSecret can be read from any service, a Secret is declared by the service that reads it, and Json<T> deserializes a JSON value into a struct:
use encore::secrets::{Json as SecretJson, Secret, SharedSecret};
use serde::Deserialize;
// One value for the whole app, set per environment.
static STRIPE_KEY: SharedSecret = SharedSecret::new("StripeKey");
// Belongs to this service only.
static WEBHOOK_SIGNING_KEY: Secret = Secret::new("WebhookSigningKey");
// A secret stored as JSON and read into a typed struct.
#[derive(Deserialize)]
struct SmtpCredentials {
host: String,
user: String,
password: String,
}
static SMTP: SharedSecret<SecretJson<SmtpCredentials>> = SharedSecret::new("Smtp");
The handler reads values where it uses them and returns only facts about them, never the values:
use axum::{http::StatusCode, Json};
use serde::Serialize;
#[derive(Serialize)]
struct Status {
stripe_mode: &'static str,
webhook_key_set: bool,
smtp_host: String,
smtp_user: String,
smtp_password_len: usize,
}
async fn status() -> Result<Json<Status>, StatusCode> {
// Take a handle where the value is used, so a rotated value is picked up.
let stripe = STRIPE_KEY.value().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
let smtp = SMTP.value().map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(Status {
stripe_mode: if stripe.starts_with("sk_live_") { "live" } else { "test" },
webhook_key_set: WEBHOOK_SIGNING_KEY.value().is_ok(),
smtp_host: smtp.host.clone(),
smtp_user: smtp.user.clone(),
smtp_password_len: smtp.password.len(),
}))
}
main initializes Encore and serves the router on the address Encore assigns, with graceful shutdown:
use axum::{routing::get, Router};
#[tokio::main]
async fn main() {
encore::initialize();
let app = Router::new().route("/billing/status", get(status));
let addr = std::env::var("ENCORE_LISTEN_ADDR").unwrap_or("0.0.0.0:8080".into());
let listener = tokio::net::TcpListener::bind(addr).await.unwrap();
axum::serve(listener, app)
.with_graceful_shutdown(encore::shutdown::signal())
.await
.unwrap();
std::process::exit(encore::shutdown::drained().await.code());
}
Local values live in .secrets.user.toml, which is git-ignored. Shared secrets go under [shared] and service secrets under the service name:
[shared]
Smtp = '{"host":"smtp.example.com","user":"app","password":"s3cret-pass"}'
StripeKey = "sk_test_51abc"
[services.billing]
WebhookSigningKey = "whsec_123"
Under encore run the status route reports test mode, and production values are set once per environment type:
On deploy, values are stored in Secrets Manager on AWS or Secret Manager on GCP in your own account, and Encore provisions an IAM role for the service.
An agent wiring up a new provider adds a static next to the client code and a local value, so the secret shows up in the diff and in review.
.value() returns an error when no value is set for the environment, which the handler maps to a 500 instead of panicking. Do not derive Debug on structs like SmtpCredentials that hold raw values, or a stray {:?} will print the password.
The common Rust setup loads a .env file with dotenvy and wraps values in secrecy's SecretString, which redacts Debug output:
use secrecy::{ExposeSecret, SecretString};
#[derive(Clone)]
struct Config {
stripe_key: SecretString,
webhook_signing_key: SecretString,
}
fn load_config() -> Config {
dotenvy::dotenv().ok(); // Local only; production sets real env vars.
Config {
stripe_key: std::env::var("STRIPE_KEY").expect("STRIPE_KEY").into(),
webhook_signing_key: std::env::var("WEBHOOK_SIGNING_KEY").expect("WEBHOOK_SIGNING_KEY").into(),
}
}
fn is_live(cfg: &Config) -> bool {
cfg.stripe_key.expose_secret().starts_with("sk_live_")
}
Pass Config into handlers through Axum's State extractor. The app reads values but does not store them, so the platform has to put encrypted values into the environment, and every secret is visible to the whole process.
With the AWS SDK for Rust, the service fetches a JSON secret at startup:
let config = aws_config::load_from_env().await;
let client = aws_sdk_secretsmanager::Client::new(&config);
let out = client.get_secret_value().secret_id("billing/production").send().await?;
let smtp: SmtpCredentials = serde_json::from_str(out.secret_string().unwrap_or_default())?;
You create a secret per environment, grant the task role secretsmanager:GetSecretValue on each, and decide what local development uses. ECS can inject values as environment variables at task start instead.
On GCP, Cloud Run can expose secrets as environment variables, so the Rust code only reads std::env:
printf %s "$STRIPE_KEY" | gcloud secrets create stripe-key --data-file=-
gcloud secrets add-iam-policy-binding stripe-key \
--member="serviceAccount:billing@$PROJECT.iam.gserviceaccount.com" \
--role=roles/secretmanager.secretAccessor
gcloud run deploy billing --image="$IMAGE" \
--service-account="billing@$PROJECT.iam.gserviceaccount.com" \
--set-secrets=STRIPE_KEY=stripe-key:latest
Each secret needs an IAM binding for the service account, which has to exist first. Values pinned to latest are read when an instance starts, so a rotation reaches running instances only after they restart.
Rust support in Encore is in beta, and you can sign up for access to try it with your Axum app.
Keep values out of the binary and the repository, load them per environment and read them where they are used. With Encore, declare static STRIPE_KEY: SharedSecret = SharedSecret::new("StripeKey") or static WEBHOOK_SIGNING_KEY: Secret = Secret::new("WebhookSigningKey") and call .value() in the handler. Local values come from .secrets.user.toml and production values from encore secret set.
Store the value as JSON and deserialize it into a struct. With Encore, declare SharedSecret<Json<SmtpCredentials>> where SmtpCredentials derives Deserialize, and .value() returns the typed struct.
Wrap them in a type that redacts Debug output, such as secrecy's SecretString, and never derive Debug or Serialize on structs that hold raw values. Return derived facts, like whether a key is set, instead of the value.
Environment variables work for delivery, and dotenvy loads a local .env file into them. Something still has to store production values encrypted and set them for each environment.
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.