Keep API keys and webhook signing secrets out of your Gin repository and environment files, set them per environment, and read them in handlers and middleware, with Encore or with AWS Secrets Manager and ECS.

A Gin service that receives webhooks and calls a partner API carries at least two secrets: the key the partner signs webhooks with and the key for calling them back. They usually start in a .env file that someone copies over Slack, end up as plain environment variables in a task definition, and sit readable in the working tree where anyone, or any coding agent, with access to the repository can print them.
This guide adds both secrets to a Gin shipping-webhook receiver. A middleware checks an HMAC-SHA256 signature on the raw body, and the handler calls the carrier's API with a bearer key when a shipment hits an exception. The code is shown with Encore first, then with .env files locally and AWS Secrets Manager in production.
Encore works alongside Gin and handles the infrastructure the Go code declares, including secrets, in your own AWS or GCP account. Gin keeps its router and middleware behind a raw fallback route, and a secret is a field on a package-level struct that Encore fills in at startup.
Run in your terminal to get started locally.
// webhooks/webhooks.go
package webhooks
import (
"bytes"
"context"
"crypto/hmac"
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"net/http"
"time"
"github.com/gin-gonic/gin"
)
// Values are set per environment with `encore secret set` and are
// never stored in the repository.
var secrets struct {
ShippingWebhookSecret string // HMAC key the carrier signs webhooks with
ShippingAPIKey string // key for calling the carrier's API
}
//encore:service
type Service struct {
router *gin.Engine
}
func initService() (*Service, error) {
r := gin.New()
r.Use(gin.Recovery())
r.POST("/webhooks/shipping", verifySignature(), shippingEvent)
return &Service{router: r}, nil
}
//encore:api public raw path=/!fallback
func (s *Service) Fallback(w http.ResponseWriter, req *http.Request) {
s.router.ServeHTTP(w, req)
}
// verifySignature checks the X-Signature header against an HMAC-SHA256
// of the raw body before any handler parses it.
func verifySignature() gin.HandlerFunc {
return func(c *gin.Context) {
body, err := io.ReadAll(io.LimitReader(c.Request.Body, 1<<20))
if err != nil {
c.AbortWithStatus(http.StatusBadRequest)
return
}
mac := hmac.New(sha256.New, []byte(secrets.ShippingWebhookSecret))
mac.Write(body)
want := hex.EncodeToString(mac.Sum(nil))
if !hmac.Equal([]byte(want), []byte(c.GetHeader("X-Signature"))) {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "bad signature"})
return
}
c.Request.Body = io.NopCloser(bytes.NewReader(body))
c.Next()
}
}
type shipmentEvent struct {
TrackingNumber string `json:"tracking_number"`
Status string `json:"status"`
}
func shippingEvent(c *gin.Context) {
var ev shipmentEvent
if err := c.ShouldBindJSON(&ev); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if ev.Status == "exception" {
// Ask the carrier for details using the API key.
if err := fetchDetails(c.Request.Context(), ev.TrackingNumber); err != nil {
c.JSON(http.StatusBadGateway, gin.H{"error": "carrier unavailable"})
return
}
}
c.Status(http.StatusNoContent)
}
func fetchDetails(ctx context.Context, tracking string) error {
ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
"https://api.carrier.example/v1/shipments/"+tracking, nil)
if err != nil {
return err
}
req.Header.Set("Authorization", "Bearer "+secrets.ShippingAPIKey)
resp, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return fmt.Errorf("carrier returned %d", resp.StatusCode)
}
return nil
}
Values are set from the CLI per environment type, so the sandbox key from the carrier goes to local and development environments and the live key only to production:
encore run fetches the local values on startup and leaves a secret empty with a warning if none is set, while a deploy to a cloud environment fails before it starts when a declared secret has no value there, as the secrets docs explain. The values are stored by Encore and never written into the repository, so a coding agent working in the code sees the field names and none of the keys, and cannot paste a production credential into a log line or a commit. In your own AWS account Encore keeps each value in Secrets Manager and injects it into the service at runtime with access limited to the service that declares it, and on GCP it uses Secret Manager the same way.
Rotating the carrier key is one encore secret set --type prod ShippingAPIKey followed by a deploy, with no task definition to edit, and adding a third secret is one more struct field and one more command. For a Postgres connection or a queue there is no secret at all, since Encore wires databases and topics without connection strings in code.
Best fit: Gin services that want API keys and signing secrets declared next to the code that uses them, kept out of the repository, and stored in Secrets Manager or Secret Manager in their own cloud account.
The hand-built version reads both values from the environment, with godotenv loading a git-ignored .env during local development:
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; production has no .env file
webhookSecret := mustEnv("SHIPPING_WEBHOOK_SECRET")
apiKey := mustEnv("SHIPPING_API_KEY")
r := gin.New()
r.Use(gin.Recovery())
r.POST("/webhooks/shipping", verifySignature(webhookSecret), shippingEvent(apiKey))
log.Fatal(r.Run(":8080"))
}
In production each value becomes a secret in AWS Secrets Manager, referenced by ARN in the container definition's secrets block so ECS injects it at task start, with secretsmanager:GetSecretValue on those ARNs granted to the task execution role, as the ECS secrets guide shows. Each environment needs its own secrets and its own ARNs in its task definition, a rotated value only reaches running tasks after a new deployment, and the .env file stays in the working tree on every developer machine, where a coding agent can read it as easily as the source.
Best fit: teams already running ECS with Secrets Manager managed in Terraform, or that need automatic rotation through a Lambda rotation function.
Pick Encore to keep Gin and declare secrets 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 plus Secrets Manager and ECS when the secrets already live in Terraform, and keep .env in .gitignore from the first commit.
From the process environment or a secrets manager at startup, never from constants or a committed .env file. In production on AWS, store them in Secrets Manager and let ECS inject them into the container, and give each environment its own values so a staging key cannot reach production.
Declare var secrets struct { ShippingAPIKey string } in the service package, set values with encore secret set --type prod ShippingAPIKey and encore secret set --type dev,local ShippingAPIKey, and read secrets.ShippingAPIKey in Gin handlers or middleware. On AWS Encore stores the value in Secrets Manager in your account and injects it at runtime.
Read the raw body in a middleware before any binding, compute an HMAC-SHA256 of it with the shared secret, compare it to the signature header with hmac.Equal, and put the body back on c.Request.Body so the handler can bind it. Abort with 401 when the signatures differ.
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.