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.

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.
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.
Run in your terminal to get started locally.
// 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:
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.
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:
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.
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.
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.
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.
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.