DEV Community

Cover image for Part 7: Secrets Management
Matthew
Matthew

Posted on

Part 7: Secrets Management

Part 7: Secrets Management — AWS Secrets Manager + External Secrets Operator + IRSA

Part of the series: Building a Production-Grade DevSecOps Pipeline on AWS


Introduction

Secrets management is one of the areas where teams most commonly take shortcuts that later become security incidents. The three anti-patterns to avoid:


IRSA Secrets Flow — How Kubernetes pods access AWS Secrets Manager without <br>
static credentials using IAM Roles for Service Accounts and OIDC token <br>
exchange

The pod never holds AWS credentials. It exchanges a short-lived Kubernetes
JWT (1 hour) for temporary IAM credentials via AWS STS, then ESO fetches
the secret and creates a Kubernetes Secret.


  • Secrets in Git — even in a private repo, any developer with access can read them; git history preserves them forever even after deletion
  • Secrets baked into images — anyone who pulls the image gets the secrets
  • Secrets in Kubernetes ConfigMaps — ConfigMaps are not encrypted by default; any pod in the namespace can read them

This pipeline uses a three-layer approach:

  1. AWS Secrets Manager — the source of truth; all secrets live here, encrypted with KMS
  2. External Secrets Operator (ESO) — a Kubernetes controller that fetches secrets from AWS and creates Kubernetes Secret objects
  3. IRSA (IAM Roles for Service Accounts) — pod-level AWS identity so ESO can call Secrets Manager without node-level credentials

Architecture

┌──────────────────────────────────────────────────────────────────────┐
│  SECRETS FLOW                                                        │
│                                                                      │
│  AWS Secrets Manager                                                 │
│  production/myapp/db-password: "s3cr3t-v@lue"                        │
│         │                                                            │
│         │  GetSecretValue (every 1h)                                 │
│         │  Authenticated via IRSA (OIDC token → STS → temp creds)    │
│         ▼                                                            │
│  ESO Operator Pod                                                    │
│  (external-secrets namespace)                                        │
│         │                                                            │
│         │  Creates/updates                                           │
│         ▼                                                            │
│  Kubernetes Secret: myapp-db-password                                │
│  (myapp namespace)                                                   │
│         │                                                            │
│         │  Mounted as env var                                        │
│         ▼                                                            │
│  myapp Pod: process.env.DB_PASSWORD = "s3cr3t-v@lue"                 │
└──────────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

IRSA Deep Dive

IRSA (IAM Roles for Service Accounts) is the mechanism that gives individual Kubernetes pods fine-grained AWS IAM permissions without sharing credentials at the node level.

┌────────────────────────────────────────────────────────────────────┐
│  HOW IRSA WORKS                                                    │
│                                                                    │
│  1. EKS creates an OIDC provider for the cluster                   │
│     URL: oidc.eks.us-east-1.amazonaws.com/id/CLUSTER_ID            │
│                                                                    │
│  2. Pod's ServiceAccount is annotated:                             │
│     eks.amazonaws.com/role-arn: arn:aws:iam::ACCT:role/eso-role    │
│                                                                    │
│  3. EKS projects a signed OIDC JWT into the pod at:                │
│     /var/run/secrets/eks.amazonaws.com/serviceaccount/token        │
│                                                                    │
│  4. ESO SDK calls sts:AssumeRoleWithWebIdentity with that token    │
│                                                                    │
│  5. AWS validates: token signed by trusted OIDC provider?          │
│                    sub matches trust policy condition?             │
│                                                                    │
│  6. AWS returns temporary creds (AccessKeyId + SecretKey + Token)  │
│     Valid for 1 hour, then automatically expire                    │
│                                                                    │
│  Result: only THIS pod in THIS namespace with THIS SA can          │
│  assume the role — not any other pod on the same node              │
└────────────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Why IRSA over node-level IAM?

Node-level IAM (EC2 instance profile) gives every pod on the node the same permissions. If one pod is compromised, the attacker has the permissions of every pod on that node. IRSA scopes permissions to the exact service account — the blast radius of a compromise is just that one workload.


Terraform: ESO IRSA Module

# _modules/eso-irsa/main.tf

variable "cluster_name"      { type = string }
variable "env"               { type = string }
variable "oidc_provider_arn" { type = string }
variable "oidc_provider"     { type = string }  # URL without https://
variable "account_id"        { type = string }
variable "aws_region"        { type = string }

locals {
  # Helm fullname = {release}-{chart}
  # Release name = cluster name (e.g., myapp-production-use1)
  # Chart name = myapp
  # So the SA name = myapp-production-use1-myapp
  #
  # CRITICAL: The IRSA trust policy sub claim must match this exactly.
  # Getting this wrong = ESO pods cannot assume the role = secrets don't sync.
  sa_name   = "${var.cluster_name}-myapp"
  namespace = "external-secrets"
}

resource "aws_iam_role" "eso" {
  name = "${var.cluster_name}-eso"

  assume_role_policy = jsonencode({
    Version = "2012-10-17"
    Statement = [{
      Effect    = "Allow"
      Principal = { Federated = var.oidc_provider_arn }
      Action    = "sts:AssumeRoleWithWebIdentity"
      Condition = {
        StringEquals = {
          "${var.oidc_provider}:aud" = "sts.amazonaws.com"
          # Must match: system:serviceaccount:{namespace}:{sa-name}
          "${var.oidc_provider}:sub" = "system:serviceaccount:${local.namespace}:${local.sa_name}"
        }
      }
    }]
  })
}

resource "aws_iam_role_policy" "eso" {
  name = "eso-secrets-policy"
  role = aws_iam_role.eso.id

  policy = jsonencode({
    Version = "2012-10-17"
    Statement = [
      {
        Sid    = "SecretsManagerRead"
        Effect = "Allow"
        Action = [
          "secretsmanager:GetSecretValue",
          "secretsmanager:DescribeSecret",
          "secretsmanager:ListSecretVersionIds"
        ]
        # Wildcard covers all secrets for this environment
        Resource = "arn:aws:secretsmanager:*:${var.account_id}:secret:${var.env}/myapp/*"
      }
    ]
  })
}

output "role_arn" { value = aws_iam_role.eso.arn }
Enter fullscreen mode Exit fullscreen mode

Creating Secrets in AWS Secrets Manager

Use a structured naming convention: {env}/myapp/{secret-name}

# Development secrets (dev account)
aws secretsmanager create-secret \
  --name "dev/myapp/db-password" \
  --secret-string '{"password":"dev-db-secret-here"}' \
  --region us-east-1 \
  --profile myapp-dev-use1

# Production secrets (production account)
aws secretsmanager create-secret \
  --name "production/myapp/db-password" \
  --secret-string '{"password":"prod-super-secret-here"}' \
  --region us-east-1 \
  --profile myapp-prod-use1

# To update a secret (rotation):
aws secretsmanager update-secret \
  --secret-id "production/myapp/db-password" \
  --secret-string '{"password":"new-rotated-password"}' \
  --region us-east-1 \
  --profile myapp-prod-use1
# ESO picks up the new value within refreshInterval (1h default)
Enter fullscreen mode Exit fullscreen mode

ESO Installation via ArgoCD

ESO runs as a controller in the external-secrets namespace on every cluster:

# infrastructure/eso/applicationset.yaml
apiVersion: argoproj.io/v1alpha1
kind: ApplicationSet
metadata:
  name: external-secrets-operator
  namespace: argocd
spec:
  generators:
    - list:
        elements:
          - cluster: myapp-dev-use1
            region:  us-east-1
          - cluster: myapp-dev-usw2
            region:  us-west-2
          - cluster: myapp-staging-use1
            region:  us-east-1
          - cluster: myapp-staging-usw2
            region:  us-west-2
          - cluster: myapp-production-use1
            region:  us-east-1
          - cluster: myapp-production-usw2
            region:  us-west-2
  template:
    metadata:
      name: "eso-{{cluster}}"
    spec:
      project: production
      source:
        repoURL:        https://charts.external-secrets.io
        chart:          external-secrets
        targetRevision: "0.9.13"
        helm:
          values: |
            serviceAccount:
              annotations:
                eks.amazonaws.com/role-arn: "{{irsaRoleArn}}"
      destination:
        name:      "{{cluster}}"
        namespace: external-secrets
      syncPolicy:
        syncOptions: [CreateNamespace=true]
Enter fullscreen mode Exit fullscreen mode

SecretStore CRD

The SecretStore tells ESO where to find secrets and how to authenticate:

# apps/myapp/templates/secretstore.yaml
{{- if .Values.externalSecrets.enabled }}
apiVersion: external-secrets.io/v1beta1
kind: SecretStore
metadata:
  name: aws-secrets-manager
  namespace: {{ .Release.Namespace }}
spec:
  provider:
    aws:
      service: SecretsManager
      region: us-east-1   # Always pull from us-east-1 (single source of truth)
      auth:
        jwt:
          serviceAccountRef:
            name: {{ include "myapp.serviceAccountName" . }}
            # DO NOT add namespace: field here.
            # Namespaced SecretStore rejects serviceAccountRef.namespace.
            # The SA is in the same namespace as the SecretStore.
{{- end }}
Enter fullscreen mode Exit fullscreen mode

Lesson learned: A namespaced SecretStore (not ClusterSecretStore) will reject the config if serviceAccountRef.namespace is specified. The namespace is implicit — it's the same namespace as the SecretStore itself. Only ClusterSecretStore supports cross-namespace references.


ExternalSecret CRD

The ExternalSecret tells ESO which secret to fetch and what Kubernetes Secret to create:

# apps/myapp/templates/external-secret.yaml
{{- if .Values.externalSecrets.enabled }}
apiVersion: external-secrets.io/v1beta1
kind: ExternalSecret
metadata:
  name: myapp-secrets
  namespace: {{ .Release.Namespace }}
spec:
  refreshInterval: {{ .Values.externalSecrets.refreshInterval | default "1h" }}

  secretStoreRef:
    name: aws-secrets-manager
    kind: SecretStore

  target:
    name: myapp-db-credentials    # Name of the Kubernetes Secret to create
    creationPolicy: Owner         # ESO owns this secret — it will delete it if ExternalSecret is deleted

  data:
    - secretKey: DB_PASSWORD       # Key in the Kubernetes Secret
      remoteRef:
        key: production/myapp/db-password   # Secret name in AWS Secrets Manager
        property: password                  # JSON key within the secret value
{{- end }}
Enter fullscreen mode Exit fullscreen mode

Helm Chart Integration

# apps/myapp/values.yaml (defaults — disabled)
externalSecrets:
  enabled:         false
  refreshInterval: 1h
  irsaRoleArn:     ""

# apps/myapp/values-production.yaml
externalSecrets:
  enabled:     true
  irsaRoleArn: ""   # Injected per-cluster via ApplicationSet parameter

# apps/myapp/values-dev.yaml
externalSecrets:
  enabled:     true
  irsaRoleArn: "arn:aws:iam::557702566877:role/myapp-dev-eso"
Enter fullscreen mode Exit fullscreen mode

The ApplicationSet injects the correct IRSA role ARN per cluster via parameters:

parameters:
  - name: "externalSecrets.irsaRoleArn"
    value: "{{irsaRoleArn}}"
Enter fullscreen mode Exit fullscreen mode

Deployment Mounting the Secret

# apps/myapp/templates/deployment.yaml (relevant section)
env:
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef:
        name: myapp-db-credentials   # Created by ESO from ExternalSecret
        key: DB_PASSWORD
Enter fullscreen mode Exit fullscreen mode

Verification

# Check ExternalSecret sync status
kubectl --context prod-use1 get externalsecret -n myapp
# NAME            STORE                 REFRESH INTERVAL   STATUS          READY
# myapp-secrets   aws-secrets-manager   1h                 SecretSynced    True

# Check the Kubernetes Secret was created
kubectl --context prod-use1 get secret myapp-db-credentials -n myapp
# NAME                   TYPE     DATA   AGE
# myapp-db-credentials   Opaque   1      5m

# View (base64 encoded — decode to see value)
kubectl --context prod-use1 get secret myapp-db-credentials -n myapp \
  -o jsonpath='{.data.DB_PASSWORD}' | base64 -d
Enter fullscreen mode Exit fullscreen mode

ArgoCD false positive: ArgoCD will show the myapp Application as OutOfSync even when everything is working. This is because ESO writes status.refreshTime to the ExternalSecret at every sync cycle. ArgoCD detects this runtime write as a diff from the Git-defined manifest. This is a known, safe false positive — the secret IS syncing correctly.


Secret Rotation

When you need to rotate a secret:

# 1. Update in AWS Secrets Manager
aws secretsmanager update-secret \
  --secret-id "production/myapp/db-password" \
  --secret-string '{"password":"new-rotated-value"}' \
  --profile myapp-prod-use1

# 2. ESO picks it up automatically within refreshInterval (1h)
# OR force immediate refresh:
kubectl --context prod-use1 annotate externalsecret myapp-secrets -n myapp \
  force-sync=$(date +%s) --overwrite

# 3. Kubernetes Secret is updated
# 4. Pods need restart to pick up new env var value:
kubectl --context prod-use1 rollout restart deployment/myapp -n myapp
Enter fullscreen mode Exit fullscreen mode

Grafana Admin Secret — A Real-World Lesson

During this build, we used existingSecret: grafana-admin-secret in the kube-prometheus-stack values. The secret was created with a placeholder password YourStrongPassword123!. Grafana initialized its SQLite database with this value when the pod first started.

Later, the secret value was changed — but Grafana's database still had the old value. The env var GF_SECURITY_ADMIN_PASSWORD only sets the initial password; it cannot change an existing one.

Fix:

kubectl exec -n monitoring <grafana-pod> -c grafana -- \
  grafana-cli admin reset-admin-password 'NewPassword!'
Enter fullscreen mode Exit fullscreen mode

Lesson: For any tool that reads credentials once at database initialization, changing the Kubernetes Secret is not enough. You must either reset via the tool's CLI or destroy and recreate the persistent volume.


Summary

By the end of Part 7 you have:

  • ✅ All secrets stored exclusively in AWS Secrets Manager (never in Git or images)
  • ✅ ESO installed on all 6 clusters via ArgoCD ApplicationSet
  • ✅ IRSA roles per cluster with least-privilege Secrets Manager read access
  • ✅ SecretStore + ExternalSecret CRDs syncing secrets into Kubernetes
  • ✅ Helm chart values.yaml integration with enabled/disabled toggle per environment
  • ✅ Secret rotation workflow (update in ASM → ESO auto-refreshes)

Screenshot Placeholders

SCREENSHOT: AWS Secrets Manager console showing secrets per environment
Show in frame: The secret names dev/myapp/db-password and production/myapp/db-password in the list. This shows the naming convention.

SCREENSHOT: kubectl get externalsecret showing SecretSynced: True
Show in frame: Output showing STATUS: SecretSynced and READY: True. This is in the Live Data Appendix already but a terminal screenshot is more visual.

SCREENSHOT: ArgoCD showing ESO app as OutOfSync/Healthy (expected false positive)
ArgoCD showing ESO app as OutOfSync/Healthy (expected false positive


Next: Part 8 — Security Stack: Kyverno, Falco, WAF, and GuardDuty


Follow the series — next part publishes next Wednesday.
Live system: https://www.matthewoladipupo.dev/health
Runbook: Operations Guide
Source code: myapp-infra | myapp-gitops | myapp

Top comments (0)