Nov 12, 20267 min read

How to Manage Secrets in an Echo App

Keep a JWT signing key and a third-party API key out of your Echo repository, set them per environment, and read them in middleware and handlers, with Encore or with .env files, Google Secret Manager and Cloud Run.

Illustration for How to Manage Secrets in an Echo App

An Echo API behind a mobile app tends to hold two kinds of secrets: the key that session tokens are signed with, and keys for paid third-party APIs the app should never carry itself. Both usually begin life in a .env file next to main.go, get pasted into a deploy script, and stay readable in the working tree for anyone, or any coding agent, that opens the repository.

This guide adds both to an Echo location API whose routes under /v1 require a session token checked by echo-jwt with an HS256 key, and a geocoding endpoint calls a maps provider with a server-side API key so the key never ships inside the app. The setup is shown with Encore first, then with .env files locally and Google Secret Manager on Cloud Run.

Secret options for an Echo app at a glance

Encore.env + Secret Manager + Cloud Run
Secret declared inA Go struct in the service--set-secrets flags and a .env file
Values set withencore secret set or the dashboard, per environment typegcloud secrets versions add per project, .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 accountSecret Manager you manage
Access grantSet up by Encore on deployroles/secretmanager.secretAccessor per secret
Missing valueDeploy fails before it startsRevision fails to start, or reads an empty string

How do you manage secrets in Echo with Encore?

Encore

Encore works alongside Echo and takes care of the infrastructure your Go code declares, including secrets, in your own AWS or GCP account. The Echo instance keeps its groups and middleware 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 geo-api --example=hello-world$ cd geo-api && go get github.com/labstack/echo/v4@v4.16.3 github.com/labstack/echo-jwt/v4@v4.4.0$ mkdir geo
// geo/geo.go package geo import ( "context" "errors" "fmt" "io" "net/http" "net/url" "time" echojwt "github.com/labstack/echo-jwt/v4" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" ) // Values are set per environment with `encore secret set` and are // never stored in the repository. var secrets struct { SessionSigningKey string // HS256 key the login service signs session tokens with MapsAPIKey string // server-side key for the geocoding provider } //encore:service type Service struct { e *echo.Echo } func initService() (*Service, error) { if len(secrets.SessionSigningKey) < 32 { return nil, errors.New("SessionSigningKey must be at least 32 bytes") } e := echo.New() e.Use(middleware.Recover()) // Every /v1 route requires a session token signed with SessionSigningKey. v1 := e.Group("/v1") v1.Use(echojwt.WithConfig(echojwt.Config{ SigningKey: []byte(secrets.SessionSigningKey), SigningMethod: "HS256", })) v1.GET("/geocode", geocode) return &Service{e: e}, nil } //encore:api public raw path=/!fallback func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) { s.e.ServeHTTP(w, req) } var client = &http.Client{Timeout: 5 * time.Second} // geocode proxies an address lookup so the maps key stays on the server // instead of shipping inside the mobile app. func geocode(c echo.Context) error { q := c.QueryParam("q") if q == "" || len(q) > 200 { return echo.NewHTTPError(http.StatusBadRequest, "q is required (max 200 chars)") } body, err := lookup(c.Request().Context(), q) if err != nil { return echo.NewHTTPError(http.StatusBadGateway, "geocoding unavailable") } return c.JSONBlob(http.StatusOK, body) } func lookup(ctx context.Context, q string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, "https://maps.example.com/v1/geocode?address="+url.QueryEscape(q), nil) if err != nil { return nil, err } req.Header.Set("X-Api-Key", secrets.MapsAPIKey) resp, err := client.Do(req) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, fmt.Errorf("maps provider returned %d", resp.StatusCode) } return io.ReadAll(io.LimitReader(resp.Body, 1<<20)) }

The signing key gets a separate value per environment type, so a token minted against a laptop or a preview environment fails verification in production, and the maps provider's restricted test key goes to development while the live key only reaches production:

~/geo-api
$ openssl rand -base64 48 | encore secret set --type dev,local SessionSigningKey$ encore secret set --type dev,local MapsAPIKey$ openssl rand -base64 48 | encore secret set --type prod SessionSigningKey$ encore secret set --type prod MapsAPIKey

encore run pulls the local values onto the developer's machine at startup, and with the length check in initService a missing signing key stops the service right away instead of letting echo-jwt accept tokens 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. With no .env file and no key in the source, a coding agent editing the geocoding handler works with secrets.MapsAPIKey as a name and never sees the value it holds.

On deploy, Encore replicates each value into the secrets manager in your own cloud account, Secret Manager on GCP or Secrets Manager on AWS, and the service receives it as an environment variable at runtime. Rotating the signing key is one encore secret set --type prod SessionSigningKey and a redeploy, and existing sessions then need to sign in again unless the login service accepts both keys for a while. The same service can later declare a cache for geocoding results, and that one is wired up by Encore with no connection string to add to the secrets struct.

Best fit: Echo APIs that want signing keys 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, Secret Manager and Cloud Run

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; Cloud Run has no .env file signingKey := mustEnv("SESSION_SIGNING_KEY") mapsKey := mustEnv("MAPS_API_KEY") e := echo.New() e.Use(middleware.Recover()) v1 := e.Group("/v1") v1.Use(echojwt.WithConfig(echojwt.Config{SigningKey: []byte(signingKey)})) v1.GET("/geocode", geocodeHandler(mapsKey)) e.Logger.Fatal(e.Start(":" + os.Getenv("PORT"))) }

In production each value is a secret in Google Secret Manager, and the Cloud Run service maps it to an environment variable:

~/geo-api
$ openssl rand -base64 48 | gcloud secrets create session-signing-key --data-file=-$ printf '%s' "$LIVE_MAPS_KEY" | gcloud secrets create maps-api-key --data-file=-$ gcloud secrets add-iam-policy-binding session-signing-key \ --member=serviceAccount:geo-api@$PROJECT.iam.gserviceaccount.com \ --role=roles/secretmanager.secretAccessor$ gcloud secrets add-iam-policy-binding maps-api-key \ --member=serviceAccount:geo-api@$PROJECT.iam.gserviceaccount.com \ --role=roles/secretmanager.secretAccessor$ gcloud run deploy geo-api --image=$IMAGE --region=europe-west1 \ --service-account=geo-api@$PROJECT.iam.gserviceaccount.com \ --set-secrets=SESSION_SIGNING_KEY=session-signing-key:1,MAPS_API_KEY=maps-api-key:1

The runtime service account needs roles/secretmanager.secretAccessor on each secret, and the Cloud Run secrets guide notes that environment variables are resolved when an instance starts, so it recommends pinning a version number instead of latest, which means every rotation is a new secret version plus a new revision that references it. Staging needs its own secrets, bindings and flags, usually in a separate project, 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 Echo on Cloud Run with Secret Manager managed in Terraform or gcloud scripts, or that mount secrets as volumes to pick up rotated values without a redeploy.

How to choose

Pick Encore to keep Echo as it is, declare the signing key 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 Secret Manager and Cloud Run when the secrets already live in a GCP project you manage by hand, and add .env to .gitignore before the first commit.

Frequently asked questions

Where should an Echo app keep its JWT signing key?

Load it at startup from the environment or a secrets manager rather than from the code or a committed file, refuse to start if it is missing or shorter than 32 bytes, and pass it to echojwt.WithConfig as SigningKey. Give staging and production different keys so a token from one is rejected by the other.

How do I manage secrets in an Echo app with Encore?

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

Why proxy a maps or geocoding API key through an Echo backend?

Any key compiled into a mobile app or shipped to a browser can be extracted and reused on your bill. Keep the key on the server, expose a small authenticated endpoint such as GET /v1/geocode, and have the Echo handler add the key to the outgoing request.

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