Web applications that use cookie-based sessions need cryptographic keys to sign and encrypt session tokens. These keys must be consistent across server restarts (so existing sessions survive deployments) and across multiple instances (so any server can validate any session). Similarly, applications that serve HTTPS using Let’s Encrypt autocert need to persist certificate material so that certificates aren’t re-issued on every restart, avoiding rate limits and unnecessary latency on first requests.
The pattern explored here solves both problems by using AWS Secrets Manager as the canonical source of truth for session signing keys and TLS certificate material. On startup, the application fetches keys from Secrets Manager. If no keys exist (first deployment), it generates fresh cryptographic material, stores it in Secrets Manager, and proceeds. For autocert, a custom cache implementation mirrors Let’s Encrypt certificates between a local DirCache (for fast reads) and Secrets Manager (for durability across deployments).
In a typical web application, this pattern bootstraps secure cookie keys from AWS Secrets Manager on each deploy, backfills missing keys when a new environment is provisioned, and mirrors Let’s Encrypt autocert material into Secrets Manager with a local DirCache fallback. This ensures that rolling deployments, container restarts, and new instances all serve consistent sessions and valid HTTPS certificates without manual key management.
Cookie-based session frameworks (Gorilla sessions, Express sessions, etc.) use cryptographic keys for two operations: signing (HMAC) to detect tampering, and optionally encrypting (AES) to hide session contents. These keys are typically 32 or 64 bytes of cryptographically random data. The security of every active session depends on these keys—if they’re lost, all sessions are invalidated; if they’re leaked, all sessions can be forged.
Session frameworks like Gorilla sessions accept multiple key pairs. The first pair is used for new sessions; older pairs are used only for validation. This allows key rotation without immediately invalidating all active sessions: add the new key at position 0, keep the old key at position 1, and remove it after the maximum session lifetime has elapsed.
Secrets Manager provides encrypted storage with IAM-based access control, automatic versioning, and audit trails via CloudTrail. Compared to environment variables or config files, it offers:
AWS offers both Secrets Manager and SSM Parameter Store (SecureString) for storing secrets. Secrets Manager costs more ($0.40/secret/month) but provides automatic rotation, cross-region replication, and resource-based policies. Parameter Store is free for standard parameters but lacks rotation automation. Use Secrets Manager for cryptographic keys and certificates where rotation and auditability matter most.
Let’s Encrypt issues free TLS certificates via the ACME protocol. Go’s golang.org/x/crypto/acme/autocert package automates the entire flow: when a TLS handshake arrives for a hostname, autocert checks its cache for a valid certificate, and if none exists, performs the ACME challenge (typically HTTP-01 or TLS-ALPN-01), obtains the certificate, and caches it. The cache interface is pluggable—the default DirCache writes to the local filesystem.
The autocert.Cache interface has three methods: Get(ctx, key), Put(ctx, key, data), and Delete(ctx, key). By implementing this interface with a Secrets Manager backend, certificate material becomes durable across container restarts and deployments. The local DirCache serves as a fast read-through cache to avoid Secrets Manager API calls on every TLS handshake.
When a new environment is provisioned or a secret is accidentally deleted, the application must generate new keys and store them rather than failing to start. This “backfill” pattern checks Secrets Manager first, falls back to generating new material if the secret doesn’t exist, stores the new material in Secrets Manager, and then proceeds with normal operation. This makes deployments self-healing without requiring manual secret provisioning as a prerequisite.
bootstrapSessionKeys()app/session-keys)package auth
import (
"context"
"crypto/rand"
"encoding/json"
"fmt"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager/types"
"github.com/gorilla/sessions"
)
type SessionKeys struct {
SigningKey []byte `json:"signing_key"`
EncryptionKey []byte `json:"encryption_key"`
// Previous keys for rotation (validation only)
PrevSigningKey []byte `json:"prev_signing_key,omitempty"`
PrevEncryptionKey []byte `json:"prev_encryption_key,omitempty"`
}
func BootstrapSessionKeys(
ctx context.Context,
client *secretsmanager.Client,
secretName string,
) (*sessions.CookieStore, error) {
keys, err := fetchOrCreateKeys(ctx, client, secretName)
if err != nil {
return nil, fmt.Errorf("bootstrap session keys: %w", err)
}
// Configure store with current + previous key pairs
store := sessions.NewCookieStore(
keys.SigningKey, keys.EncryptionKey, // current (sign + encrypt)
keys.PrevSigningKey, keys.PrevEncryptionKey, // previous (validate only)
)
store.Options.HttpOnly = true
store.Options.Secure = true
store.Options.SameSite = 3 // SameSiteStrictMode
store.Options.MaxAge = 86400 * 7 // 7 days
return store, nil
}
func fetchOrCreateKeys(
ctx context.Context,
client *secretsmanager.Client,
secretName string,
) (*SessionKeys, error) {
// Try to fetch existing keys
result, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
SecretId: aws.String(secretName),
})
if err == nil {
var keys SessionKeys
if err := json.Unmarshal([]byte(*result.SecretString), &keys); err != nil {
return nil, fmt.Errorf("decode session keys: %w", err)
}
return &keys, nil
}
// Check if secret simply doesn't exist (new environment)
var notFound *types.ResourceNotFoundException
if !errors.As(err, ¬Found) {
return nil, fmt.Errorf("fetch session keys: %w", err)
}
// Generate new keys and store them
keys := &SessionKeys{
SigningKey: generateKey(64),
EncryptionKey: generateKey(32),
}
payload, _ := json.Marshal(keys)
_, err = client.CreateSecret(ctx, &secretsmanager.CreateSecretInput{
Name: aws.String(secretName),
SecretString: aws.String(string(payload)),
Description: aws.String("Auto-generated session signing/encryption keys"),
})
if err != nil {
return nil, fmt.Errorf("store new session keys: %w", err)
}
return keys, nil
}
func generateKey(size int) []byte {
key := make([]byte, size)
if _, err := rand.Read(key); err != nil {
panic("crypto/rand failed: " + err.Error())
}
return key
}
package auth
import (
"context"
"fmt"
"os"
"path/filepath"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager/types"
"golang.org/x/crypto/acme/autocert"
)
// SecretsManagerCache implements autocert.Cache with a local DirCache
// read-through layer and Secrets Manager as the durable backend.
type SecretsManagerCache struct {
sm *secretsmanager.Client
prefix string // e.g., "app/autocert/"
localDir string // local DirCache path for fast reads
}
func NewSecretsManagerCache(
client *secretsmanager.Client,
prefix string,
localDir string,
) *SecretsManagerCache {
os.MkdirAll(localDir, 0700)
return &SecretsManagerCache{
sm: client,
prefix: prefix,
localDir: localDir,
}
}
func (c *SecretsManagerCache) Get(ctx context.Context, key string) ([]byte, error) {
// Try local cache first (fast path)
localPath := filepath.Join(c.localDir, key)
if data, err := os.ReadFile(localPath); err == nil {
return data, nil
}
// Fall back to Secrets Manager
result, err := c.sm.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
SecretId: aws.String(c.prefix + key),
})
if err != nil {
var notFound *types.ResourceNotFoundException
if errors.As(err, ¬Found) {
return nil, autocert.ErrCacheMiss
}
return nil, fmt.Errorf("autocert cache get: %w", err)
}
data := result.SecretBinary
// Populate local cache for future reads
os.WriteFile(localPath, data, 0600)
return data, nil
}
func (c *SecretsManagerCache) Put(ctx context.Context, key string, data []byte) error {
// Write to local cache
localPath := filepath.Join(c.localDir, key)
if err := os.WriteFile(localPath, data, 0600); err != nil {
return fmt.Errorf("local cache write: %w", err)
}
// Mirror to Secrets Manager
secretName := c.prefix + key
_, err := c.sm.PutSecretValue(ctx, &secretsmanager.PutSecretValueInput{
SecretId: aws.String(secretName),
SecretBinary: data,
})
if err != nil {
// Secret might not exist yet; create it
var notFound *types.ResourceNotFoundException
if errors.As(err, ¬Found) {
_, err = c.sm.CreateSecret(ctx, &secretsmanager.CreateSecretInput{
Name: aws.String(secretName),
SecretBinary: data,
Description: aws.String("Autocert certificate: " + key),
})
}
if err != nil {
return fmt.Errorf("secrets manager write: %w", err)
}
}
return nil
}
func (c *SecretsManagerCache) Delete(ctx context.Context, key string) error {
// Remove local cache
os.Remove(filepath.Join(c.localDir, key))
// Remove from Secrets Manager
_, err := c.sm.DeleteSecret(ctx, &secretsmanager.DeleteSecretInput{
SecretId: aws.String(c.prefix + key),
ForceDeleteWithoutRecovery: aws.Bool(true),
})
if err != nil {
var notFound *types.ResourceNotFoundException
if errors.As(err, ¬Found) {
return nil // Already gone
}
return fmt.Errorf("secrets manager delete: %w", err)
}
return nil
}
package main
import (
"context"
"crypto/tls"
"log"
"net/http"
"github.com/aws/aws-sdk-go-v2/config"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
"golang.org/x/crypto/acme/autocert"
"webapp/auth"
)
func main() {
ctx := context.Background()
// Initialize AWS client
cfg, err := config.LoadDefaultConfig(ctx)
if err != nil {
log.Fatalf("AWS config: %v", err)
}
smClient := secretsmanager.NewFromConfig(cfg)
// Bootstrap session keys from Secrets Manager
sessionStore, err := auth.BootstrapSessionKeys(ctx, smClient, "webapp/session-keys")
if err != nil {
log.Fatalf("Session keys: %v", err)
}
// Configure autocert with dual-layer cache
cache := auth.NewSecretsManagerCache(smClient, "webapp/certs/", "/var/cache/autocert")
certManager := &autocert.Manager{
Prompt: autocert.AcceptTOS,
HostPolicy: autocert.HostWhitelist("app.example.io", "api.example.io"),
Cache: cache,
}
// Build application handler
mux := http.NewServeMux()
mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
session, _ := sessionStore.Get(r, "app-session")
// ... handle request with session
_ = session
w.Write([]byte("OK"))
})
// HTTPS server with autocert TLS
server := &http.Server{
Addr: ":443",
Handler: mux,
TLSConfig: &tls.Config{
GetCertificate: certManager.GetCertificate,
MinVersion: tls.VersionTLS12,
},
}
// HTTP server for ACME challenges + redirect
go http.ListenAndServe(":80", certManager.HTTPHandler(nil))
log.Println("Starting HTTPS server on :443")
log.Fatal(server.ListenAndServeTLS("", ""))
}
package auth
import (
"context"
"encoding/json"
"github.com/aws/aws-sdk-go-v2/aws"
"github.com/aws/aws-sdk-go-v2/service/secretsmanager"
)
// RotateSessionKeys generates a new key pair and demotes the current
// keys to the "previous" slot for graceful session migration.
func RotateSessionKeys(
ctx context.Context,
client *secretsmanager.Client,
secretName string,
) error {
// Fetch current keys
result, err := client.GetSecretValue(ctx, &secretsmanager.GetSecretValueInput{
SecretId: aws.String(secretName),
})
if err != nil {
return err
}
var current SessionKeys
json.Unmarshal([]byte(*result.SecretString), ¤t)
// Rotate: current becomes previous, generate new current
rotated := SessionKeys{
SigningKey: generateKey(64),
EncryptionKey: generateKey(32),
PrevSigningKey: current.SigningKey,
PrevEncryptionKey: current.EncryptionKey,
}
payload, _ := json.Marshal(rotated)
_, err = client.PutSecretValue(ctx, &secretsmanager.PutSecretValueInput{
SecretId: aws.String(secretName),
SecretString: aws.String(string(payload)),
})
return err
}
When deploying new container instances, each instance fetches the same session keys from Secrets Manager. Users are never logged out during deployments because all instances share identical keys. Without centralized key storage, each container would generate its own keys, and a user whose request hits a different container would appear unauthenticated.
Secrets Manager supports cross-region replication. For applications deployed across regions with global load balancing, replicating session keys ensures that a user authenticated in us-east-1 has a valid session when their next request routes to eu-west-1. The same applies to autocert certificates—a certificate obtained in one region is available in all regions without re-issuing.
If a server’s local DirCache is lost (disk failure, container restart), the autocert cache falls through to Secrets Manager and recovers the certificate without hitting Let’s Encrypt again. This prevents rate limit exhaustion during recovery scenarios and eliminates the brief period of TLS unavailability that would occur while waiting for a new certificate issuance.