Oct 27, 20266 min read

How to Add Secrets to an Axum App

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:

  • Where production values are stored and who can read them.
  • How developers get local values.
  • How structured credentials are typed.
  • How values stay out of logs and responses.

Secrets options for Axum at a glance

CapabilityEncoredotenvy and secrecyAWS Secrets ManagerGoogle Secret Manager
Declared inRust staticsEnv var names in codeAWS console or IaCGCP console or IaC
Local values.secrets.user.toml.env fileReal secret or env varsReal secret or env vars
Typed JSONSharedSecret<Json<T>>Parse yourselfParse SecretStringParse the payload
ScopeShared or per serviceWhole processIAM policyIAM binding
Production storeSecrets Manager or Secret Manager in your accountWhatever sets env varsAWSGCP

Encore: secrets for your existing Axum app

Encore

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:

~/billing
$ encore run$ curl localhost:4000/billing/status{"stripe_mode":"test","webhook_key_set":true,"smtp_host":"smtp.example.com","smtp_user":"app","smtp_password_len":11}$ encore secret set --type production StripeKey

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.

Good to know

.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.

Try Encore →

dotenvy with secrecy

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_") }

Good to know

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.

AWS Secrets Manager

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())?;

Good to know

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.

Google Secret Manager

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

Good to know

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.

How to choose

  • Encore if you want secrets declared as Rust statics, local values in a git-ignored file and production values stored in your own AWS or GCP account.
  • dotenvy and secrecy for a small service where the platform already injects environment variables.
  • AWS Secrets Manager or Google Secret Manager directly if you manage the IAM bindings and per-environment secrets yourself.

Rust support in Encore is in beta, and you can sign up for access to try it with your Axum app.

Frequently asked questions

How do I manage secrets in an 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.

How do I store structured credentials as one Rust secret?

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.

How do I stop Rust secrets from appearing in logs?

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.

Should an Axum app read secrets from environment variables?

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.

~/orders — claude
Claude Code
Claude Code v2.1.180Opus 5.5~/orders