Nov 13, 20268 min read

How to Manage Secrets in a chi App

Keep a webhook signing secret and an email provider key out of your go-chi repository, set them per environment, and read them in chi middleware and handlers, with Encore or with .env files, AWS Secrets Manager and ECS.

Illustration for How to Manage Secrets in a chi App

A chi API that takes webhooks from a payment provider and sends email holds two secrets: the shared key that proves a webhook came from the provider, and the API key for the email service. Both tend to start in a .env file next to main.go and stay readable in the working tree for anyone, or any coding agent, that opens the repository, and a leaked signing secret lets anyone post a fake payment.succeeded event.

This guide adds both to a chi payments webhook, where a chi middleware verifies an HMAC-SHA256 signature over the timestamp and body with replay protection, and the handler emails the customer when a payment fails. The setup is shown with Encore first, then with .env files locally and AWS Secrets Manager on ECS.

Secret options for a chi app at a glance

Encore.env + Secrets Manager + ECS
Secret declared inA Go struct in the serviceThe task definition's secrets list and a .env file
Values set withencore secret set or the dashboard, per environment typeaws secretsmanager create-secret per account, .env locally
Stored in the repoNoOnly if .env is committed by mistake
Readable by coding agents in the repoNovalues are not in the working treeYes, if a .env file is present
Production storageSecrets Manager on AWS, Secret Manager on GCP, in your accountSecrets Manager you manage
Access grantSet up by Encore on deploysecretsmanager:GetSecretValue on the task execution role
Missing valueDeploy fails before it startsTask fails to start, or the app reads an empty string

How do you manage secrets in chi with Encore?

Encore

Encore works alongside chi and takes care of the infrastructure your Go code declares, including secrets, in your own AWS or GCP account. The chi router keeps its middleware and routes and receives requests through a raw fallback route, while the two keys are fields on a package-level struct that Encore fills in before the service starts.

Set up the app

Get started
brew install encoredev/tap/encore &&
encore app create
Copy

Run in your terminal to get started locally.

~
$ encore app create payments-hooks --example=hello-world$ cd payments-hooks && go get github.com/go-chi/chi/v5@v5.5.0$ mkdir -p hooks/migrations
// hooks/hooks.go package hooks import ( "bytes" "context" "crypto/hmac" "crypto/sha256" "encoding/hex" "encoding/json" "errors" "fmt" "io" "net/http" "strconv" "time" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "encore.dev/storage/sqldb" ) // Values are set per environment with `encore secret set` and are // never stored in the repository. var secrets struct { PaymentsWebhookSecret string // shared with the payment provider for signing webhooks EmailAPIKey string // server-side key for the transactional email provider } var db = sqldb.NewDatabase("hooks", sqldb.DatabaseConfig{Migrations: "./migrations"}) //encore:service type Service struct { router chi.Router } func initService() (*Service, error) { if len(secrets.PaymentsWebhookSecret) < 32 { return nil, errors.New("PaymentsWebhookSecret must be at least 32 bytes") } r := chi.NewRouter() r.Use(middleware.Recoverer) r.With(verifySignature).Post("/webhooks/payments", paymentEvent) return &Service{router: r}, nil } // Fallback sends every request that no Encore endpoint matches to chi. // //encore:api public raw path=/!fallback func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) { s.router.ServeHTTP(w, req) } const tolerance = 5 * time.Minute // verifySignature checks an HMAC-SHA256 over "timestamp.body" and rejects // requests older than five minutes. Event IDs in the handler then stop // replays that still fall inside that window. func verifySignature(next http.Handler) http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 1<<20)) if err != nil { http.Error(w, "body too large", http.StatusRequestEntityTooLarge) return } ts, err := strconv.ParseInt(r.Header.Get("X-Webhook-Timestamp"), 10, 64) if err != nil || time.Since(time.Unix(ts, 0)).Abs() > tolerance { http.Error(w, "stale or missing timestamp", http.StatusUnauthorized) return } mac := hmac.New(sha256.New, []byte(secrets.PaymentsWebhookSecret)) fmt.Fprintf(mac, "%d.", ts) mac.Write(body) got, err := hex.DecodeString(r.Header.Get("X-Webhook-Signature")) if err != nil || !hmac.Equal(got, mac.Sum(nil)) { http.Error(w, "invalid signature", http.StatusUnauthorized) return } r.Body = io.NopCloser(bytes.NewReader(body)) next.ServeHTTP(w, r) }) } type paymentPayload struct { ID string `json:"id"` Type string `json:"type"` Email string `json:"email"` } func paymentEvent(w http.ResponseWriter, r *http.Request) { var p paymentPayload if err := json.NewDecoder(r.Body).Decode(&p); err != nil || p.ID == "" { http.Error(w, "invalid json", http.StatusBadRequest) return } if _, err := db.Exec(r.Context(), ` INSERT INTO processed_events (event_id, event_type, email) VALUES ($1, $2, $3) ON CONFLICT (event_id) DO NOTHING`, p.ID, p.Type, p.Email); err != nil { http.Error(w, "could not record event", http.StatusInternalServerError) return } if err := deliverPaymentEmail(r.Context(), p.ID); err != nil { http.Error(w, "email provider unavailable", http.StatusBadGateway) return } w.WriteHeader(http.StatusNoContent) } func deliverPaymentEmail(ctx context.Context, eventID string) error { var typ, email string var processedAt *time.Time if err := db.QueryRow(ctx, ` SELECT event_type, email, processed_at FROM processed_events WHERE event_id = $1`, eventID).Scan(&typ, &email, &processedAt); err != nil { return err } if processedAt != nil { return nil } if typ == "payment.failed" && email != "" { if err := sendEmail(ctx, email, "Your payment did not go through", "payment-failed-"+eventID); err != nil { return err } } _, err := db.Exec(ctx, ` UPDATE processed_events SET processed_at = now() WHERE event_id = $1 AND processed_at IS NULL`, eventID) return err } var client = &http.Client{Timeout: 5 * time.Second} func sendEmail(ctx context.Context, to, subject, idempotencyKey string) error { body, _ := json.Marshal(map[string]string{"to": to, "subject": subject}) req, err := http.NewRequestWithContext(ctx, http.MethodPost, "https://email.example.com/v1/send", bytes.NewReader(body)) if err != nil { return err } req.Header.Set("Authorization", "Bearer "+secrets.EmailAPIKey) req.Header.Set("Idempotency-Key", idempotencyKey) req.Header.Set("Content-Type", "application/json") resp, err := client.Do(req) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode >= 300 { return fmt.Errorf("email provider returned %d", resp.StatusCode) } return nil }

Put this table in hooks/migrations/1_events.up.sql so each provider event is recorded once:

CREATE TABLE processed_events ( event_id TEXT PRIMARY KEY, event_type TEXT NOT NULL, email TEXT, processed_at TIMESTAMPTZ, received_at TIMESTAMPTZ NOT NULL DEFAULT now() );

The signing secret gets a separate value per environment type, so a webhook signed for a sandbox fails verification in production, and the email provider's test key goes to development while the live key only reaches production:

~/payments-hooks
$ encore secret set --type dev,local PaymentsWebhookSecret$ encore secret set --type dev,local EmailAPIKey$ encore secret set --type prod PaymentsWebhookSecret$ encore secret set --type prod EmailAPIKey

Paste the value the payment provider shows for each webhook endpoint. encore run pulls the local values onto the developer's machine at startup, and with the length check in initService, a missing or truncated signing secret stops the service with PaymentsWebhookSecret must be at least 32 bytes instead of accepting webhooks signed with an empty key. A deploy to a cloud environment fails before it starts when a declared secret has no value there, as covered in the secrets docs.

Run locally, a correctly signed request returns 204, while a request with a ten-minute-old timestamp or a body changed after signing returns 401. The handler persists the event first, then deliverPaymentEmail sends with Idempotency-Key: payment-failed-{id} and sets processed_at only after the provider acknowledges the send. A lost response or a failed update leaves the row pending, and the provider's retry or a later call to deliverPaymentEmail reuses the same key rather than creating a second email.

Secret values stay out of the repository, and a coding agent editing the webhook handler works with secrets.EmailAPIKey as a name, not the value it holds. On deploy, Encore replicates each value into the secrets manager in your own cloud account, Secrets Manager on AWS or Secret Manager on GCP, and the service receives it at runtime. Rotating the signing secret is one encore secret set --type prod PaymentsWebhookSecret and a redeploy, timed with the rotation on the provider side.

Best fit: chi APIs that want signing secrets and partner API keys declared next to the middleware that uses them, a separate value for each environment, and storage in their own AWS or GCP account.

.env files, Secrets Manager and ECS

The hand-built version reads both values from the environment at startup, with godotenv loading a git-ignored .env file on developer machines:

func mustEnv(name string) string { v := os.Getenv(name) if v == "" { log.Fatalf("%s is not set", name) } return v } func main() { _ = godotenv.Load() // local only; ECS tasks have no .env file webhookSecret := mustEnv("PAYMENTS_WEBHOOK_SECRET") emailKey := mustEnv("EMAIL_API_KEY") r := chi.NewRouter() r.Use(middleware.Recoverer) r.With(verifySignature(webhookSecret)).Post("/webhooks/payments", paymentEvent(emailKey)) log.Fatal(http.ListenAndServe(":8080", r)) }

In production each value is a secret in AWS Secrets Manager, and the ECS task definition maps it to an environment variable with valueFrom:

"secrets": [ { "name": "PAYMENTS_WEBHOOK_SECRET", "valueFrom": "arn:aws:secretsmanager:eu-central-1:123456789012:secret:payments-webhook-secret-AbCdEf" }, { "name": "EMAIL_API_KEY", "valueFrom": "arn:aws:secretsmanager:eu-central-1:123456789012:secret:email-api-key-GhIjKl" } ]

The task execution role needs secretsmanager:GetSecretValue on both secrets, and the ECS secrets guide notes that the values are injected when the container starts. A rotated secret only reaches the service after a new deployment, usually with Force new deployment. Staging needs its own secrets and role policy, usually in a separate account, and the .env file stays on every developer machine in the same folder as the code a coding agent reads.

Best fit: teams already running chi on ECS with Secrets Manager managed in Terraform or scripts, or that read secrets through the AWS SDK at runtime to pick up rotations without a redeploy.

How to choose

Pick Encore to keep chi as it is, declare the signing secret and API keys in Go, set them per environment from the CLI, and have them stored in Secrets Manager or Secret Manager in your own account with nothing in the repository for people or agents to read. Pick .env with Secrets Manager and ECS when the secrets already live in an AWS account you manage by hand, and add .env to .gitignore before the first commit.

Frequently asked questions

Where should a chi app keep its webhook signing secret?

Load it at startup from the environment or a secrets manager, never from the code or a committed file, and refuse to start if it is missing or too short. Pass it to the chi middleware that verifies signatures, so every route mounted with r.With(verifySignature) gets the check.

How do I manage secrets in a chi app with Encore?

Declare var secrets struct { PaymentsWebhookSecret string; EmailAPIKey string } in the service package, set values with encore secret set --type prod PaymentsWebhookSecret and encore secret set --type dev,local PaymentsWebhookSecret, and read secrets.PaymentsWebhookSecret in the chi middleware. In your own AWS or GCP account, Encore keeps the values in Secrets Manager or Secret Manager and injects them at runtime.

How do I stop replayed webhooks in a chi middleware?

Sign the timestamp together with the body, check the HMAC with hmac.Equal, and reject requests whose timestamp is more than a few minutes away from the server clock. Persist the provider event ID first, send the email with an Idempotency-Key derived from that ID, and set processed_at only after the provider acknowledges the send, so a retried webhook does not create a second email.

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