How to Apply Security Headers at the Ingress Level in Kubernetes — a Practical Guide

Prateek ShettyPrateek Shetty
9 min read

TL;DR: Enforce common security headers (CSP, HSTS, CORS, X-Frame-Options, etc.) at the ingress/gateway so you don’t have to patch every app. Use a templated ingress manifest + envsubst to inject environment-specific values. Store policies/secrets in a secret manager (Infisical, HashiCorp Vault, ExternalSecrets, etc.). Automate deployment with GitHub Actions (kubeconfig, bastion, or GitOps).


1) Why centralize headers at the gateway?

  • Single source of truth. Update headers in one place — no app-by-app drift.

  • Consistent posture. Easier audits and rollbacks.

  • Operational simplicity. One template for dev/staging/prod.

  • Testability. Verify quickly with curl, browser devtools, or security scanners.

This pattern is widely used at scale. Enterprise guidance (including Microsoft and other cloud vendors) recommends implementing guardrails as close to the edge as possible — gateway-level controls are easier to manage and audit than distributed app config.


2) High-level approach

  1. Create a simple ingress manifest template that uses ${VARS} so it’s envsubstfriendly.

  2. Store canonical policies (e.g., BASE_CSP_POLICY) in repo variables or a secret manager.

  3. Use GitHub Actions to fetch secrets, render the template, and deploy to the cluster (kubeconfig, bastion, or GitOps).

  4. Test in report-only first, then enforce.


3) Where to keep secrets & policies

  • Infisical — easy CI/CD integration and local dev secrets.

  • HashiCorp Vault — dynamic secrets, tight access controls.

  • ExternalSecrets Operator — sync secrets into K8s from Vault/GCP/AWS.

  • GitHub Actions Secrets / Repo Vars — good for non-sensitive defaults; prefer secret managers for real secrets.

  • SOPS / SealedSecrets — for encrypted GitOps workflows.

Recommendation: Put BASE_CSP_POLICY in repo vars or a secret manager (depending on sensitivity). Put kubeconfig/SSH keys and any tokens only in vault/CI secrets.


4) Ingress template (ingress-template.yaml)

Save this file in your repo. It uses ${VARS} for envsubst compatibility.

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ${INGRESS_NAME}
  annotations:
    # Optional timeouts & body size
    nginx.ingress.kubernetes.io/proxy-body-size: "${PROXY_BODY_SIZE}"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "${PROXY_READ_TIMEOUT}"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "${PROXY_SEND_TIMEOUT}"

    # Security headers (always -> returned even on errors)
    nginx.ingress.kubernetes.io/configuration-snippet: |
      proxy_hide_header X-Powered-By;

      # HSTS (only enable on HTTPS)
      add_header Strict-Transport-Security "max-age=63072000; includeSubDomains; preload" always;

      # Content-Security-Policy (templated via envsubst)
      add_header Content-Security-Policy "${CSP_POLICY}" always;

      # Clickjacking protection
      add_header X-Frame-Options "SAMEORIGIN" always;

      # Legacy XSS filter
      add_header X-XSS-Protection "1; mode=block" always;

      # Referrer policy
      add_header Referrer-Policy "strict-origin-when-cross-origin" always;

      # Permissions policy
      add_header Permissions-Policy "geolocation=(), microphone=(), camera=()" always;

      # Prevent MIME sniffing
      add_header X-Content-Type-Options "nosniff" always;

    # CORS (NGINX ingress helper annotations)
    nginx.ingress.kubernetes.io/enable-cors: "true"
    nginx.ingress.kubernetes.io/cors-allow-origin: "${CORS_ALLOW_ORIGIN}"
    nginx.ingress.kubernetes.io/cors-allow-methods: "${CORS_ALLOW_METHODS}"
    nginx.ingress.kubernetes.io/cors-allow-headers: "${CORS_ALLOW_HEADERS}"
    nginx.ingress.kubernetes.io/cors-allow-credentials: "${CORS_ALLOW_CREDENTIALS}"
    nginx.ingress.kubernetes.io/cors-max-age: "${CORS_MAX_AGE}"

    template-version: "1.2"
spec:
  ingressClassName: ${INGRESS_CLASS}
  rules:
    - host: ${HOST}
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: ${SERVICE_NAME}
                port:
                  number: ${SERVICE_PORT}

Notes

  • If your cluster disables configuration-snippet, ensure the nginx-controller ConfigMap has allow-snippet-annotations: "true" — otherwise the snippet will be ignored.

  • Only enable HSTS on HTTPS endpoints. For initial testing, prefer Content-Security-Policy-Report-Only.


5) BASE_CSP_POLICY — suggested generic starting value

Store this in a secure variable (vars.BASE_CSP_POLICY in GitHub, or a secret in Vault/Infisical). Use placeholders for dynamic domains.

default-src 'self' <https://api.example.com> <https://assets.example.com> ${ADDITIONAL_DOMAINS};
script-src 'self' 'unsafe-inline' 'unsafe-eval' <https://cdn.jsdelivr.net> <https://cdnjs.cloudflare.com> <https://www.googletagmanager.com> ${ADDITIONAL_DOMAINS};
style-src 'self' 'unsafe-inline' <https://fonts.googleapis.com> ${ADDITIONAL_DOMAINS};
img-src 'self' data: <https://images.example.com> ${ADDITIONAL_DOMAINS};
connect-src 'self' <https://api.example.com> wss://api.example.com ${ADDITIONAL_DOMAINS};
font-src 'self' <https://fonts.gstatic.com> ${ADDITIONAL_DOMAINS};
frame-ancestors 'self' ${ADDITIONAL_DOMAINS};
object-src 'none';
base-uri 'self';
form-action 'self';

How this is used: In CI you envsubst ${ADDITIONAL_DOMAINS} into BASE_CSP_POLICY (or compute the final CSP_POLICY) and then render the ingress template.


6) Render & apply locally (quick demo)

Local dev example (or copy into CI):

# Example envs you might populate from Infisical/Vault/CI
export HOST="example.com"
export INGRESS_NAME="example-ingress"
export SERVICE_NAME="my-app-service"
export SERVICE_PORT="80"
export INGRESS_CLASS="nginx"

export CORS_ALLOW_ORIGIN="<https://example.com>"
export CORS_ALLOW_METHODS="GET,POST,PUT,PATCH,DELETE,OPTIONS"
export CORS_ALLOW_HEADERS="Content-Type,Authorization,Accept,Origin,User-Agent"
export CORS_ALLOW_CREDENTIALS="true"
export CORS_MAX_AGE="86400"

export PROXY_BODY_SIZE="200m"
export PROXY_READ_TIMEOUT="3600"
export PROXY_SEND_TIMEOUT="3600"

# CSP: in practice this is loaded from a secure store
export BASE_CSP_POLICY="default-src 'self' <https://api.example.com> ${ADDITIONAL_DOMAINS}; script-src 'self' 'unsafe-inline' <https://cdn.jsdelivr.net>;"
export ADDITIONAL_DOMAINS=""
export CSP_POLICY="$(echo "${BASE_CSP_POLICY}" | envsubst)"

envsubst < ingress-template.yaml > processed-ingress.yaml
kubectl apply -f processed-ingress.yaml -n default

7) GitHub Actions workflow — full example (render, fetch secrets, deploy)

This workflow demonstrates three deploy approaches (kubeconfig, bastion, GitOps). It also shows how to build CSP_POLICY from BASE_CSP_POLICY with envsubst. Replace placeholder Infisical/Vault steps with your org's real steps or actions.

Save as .github/workflows/apply-ingress.yaml:

name: Apply Ingress Security Headers
on:
  workflow_dispatch:
    inputs:
      environment:
        description: "Namespace/env (dev/staging/prod)"
        required: true
        default: dev
      host:
        description: "Ingress host (example.com)"
        required: true
        default: example.com
      additional_domains:
        description: "Optional extra domains (space-separated)"
        required: false
        default: ""

jobs:
  render-and-deploy:
    runs-on: ubuntu-latest
    env:
      # BASE_CSP_POLICY should be defined as a repository variable (vars) or fetched from Vault/Infisical
      BASE_CSP_POLICY: ${{ vars.BASE_CSP_POLICY }}
      DEPLOY_STRATEGY: ${{ vars.DEPLOY_STRATEGY }} # "kubeconfig" | "bastion" | "gitops"
      USE_INFISCAL: ${{ vars.USE_INFISCAL }}       # "true" if using Infisical integration
      USE_VAULT: ${{ vars.USE_VAULT }}             # "true" if using Vault integration

    steps:
      - name: Checkout repo
        uses: actions/checkout@v4

      - name: Install tools
        run: |
          sudo apt-get update -y
          sudo apt-get install -y gettext-base

      ########################################################
      # Optional: fetch secrets from Infisical or Vault
      # Replace the example below with your org's method.
      ########################################################
      - name: (Optional) Infisical: inject secrets into env
        if: env.USE_INFISCAL == 'true'
        uses: infisical/infisical-action@v1  # placeholder; adapt to your real action
        with:
          token: ${{ secrets.INFISCAL_TOKEN }}

      - name: (Optional) HashiCorp Vault: fetch secrets
        if: env.USE_VAULT == 'true'
        env:
          VAULT_ADDR: ${{ secrets.VAULT_ADDR }}
        run: |
          # Placeholder: use vault CLI or community action to read secrets and export them
          echo "Vault fetch placeholder (implement your fetch step here)"

      ########################################################
      # Prepare envs, build CSP, and validate (respects secrets injected by Infisical/Vault)
      ########################################################
      - name: Prepare envs, build CSP, and validate
        shell: bash
        run: |
          # Inputs from workflow_dispatch
          HOST="${{ github.event.inputs.host }}"
          NAMESPACE="${{ github.event.inputs.environment }}"
          ADDITIONAL_DOMAINS="${{ github.event.inputs.additional_domains }}"

          # Non-sensitive defaults (safe to set here)
          : "${INGRESS_NAME:=example-ingress}"
          : "${SERVICE_NAME:=my-app-service}"
          : "${SERVICE_PORT:=80}"
          : "${INGRESS_CLASS:=nginx}"
          : "${CORS_ALLOW_ORIGIN:=https://$HOST}"
          : "${CORS_ALLOW_METHODS:=GET,POST,PUT,PATCH,DELETE,OPTIONS}"
          : "${CORS_ALLOW_HEADERS:=Content-Type,Authorization,Accept,Origin,User-Agent}"
          : "${CORS_ALLOW_CREDENTIALS:=true}"
          : "${CORS_MAX_AGE:=86400}"
          : "${PROXY_BODY_SIZE:=200m}"
          : "${PROXY_READ_TIMEOUT:=3600}"
          : "${PROXY_SEND_TIMEOUT:=3600}"

          # Critical secrets/policy:
          # If Infisical/Vault already injected BASE_CSP_POLICY, use that; otherwise fall back to repo var
          : "${BASE_CSP_POLICY:=${{ vars.BASE_CSP_POLICY }}}"

          if [[ -z "$BASE_CSP_POLICY" ]]; then
            echo "ERROR: BASE_CSP_POLICY is not set. Put it in repo vars or a secret manager and ensure it is available to the job."
            exit 1
          fi

          # Build final CSP by substituting ADDITIONAL_DOMAINS into BASE_CSP_POLICY
          export ADDITIONAL_DOMAINS
          CSP_POLICY="$(echo "${BASE_CSP_POLICY}" | envsubst)"

          # -------------------------
          # IMPORTANT NOTE FOR READERS:
          # If you use Infisical (or an equivalent secret action) that injects the final env vars
          # into the job environment with the same names used below, you DO NOT need to re-export
          # all of them to GITHUB_ENV. Re-exporting is only necessary when:
          #  - you want to set safe defaults in CI, or
          #  - you compute/transform values in the job (e.g., CSP_POLICY) and need later steps to see them,
          #  - or you need to ensure multi-line values are preserved correctly (use the heredoc method).
          #
          # The snippet below writes *only missing defaults* into GITHUB_ENV and always writes
          # the computed CSP_POLICY using heredoc to preserve multiline content.
          # This avoids clobbering envs already provided by Infisical/Vault.
          # -------------------------

          # Write only missing single-line envs to GITHUB_ENV (avoid overwriting values injected by Infisical)
          for v in HOST NAMESPACE INGRESS_NAME SERVICE_NAME SERVICE_PORT INGRESS_CLASS \\
                   CORS_ALLOW_ORIGIN CORS_ALLOW_METHODS CORS_ALLOW_HEADERS CORS_ALLOW_CREDENTIALS \\
                   CORS_MAX_AGE PROXY_BODY_SIZE PROXY_READ_TIMEOUT PROXY_SEND_TIMEOUT; do
            # If the variable is empty, skip (we already set defaults above)
            if [[ -z "${!v}" ]]; then
              continue
            fi
            # Check if GITHUB_ENV already contains this var (avoid overwriting)
            if ! (grep -q "^${v}=" "$GITHUB_ENV" 2>/dev/null); then
              echo "${v}=${!v}" >> "$GITHUB_ENV"
            fi
          done

          # Always write the computed CSP_POLICY using heredoc (preserve newlines)
          # If you trust your secret manager to already provide CSP_POLICY and you want to keep it,
          # guard this with: if [[ -z "${CSP_POLICY}" ]]; then ... fi
          echo "CSP_POLICY<<'EOF'" >> "$GITHUB_ENV"
          echo "${CSP_POLICY}" >> "$GITHUB_ENV"
          echo "EOF" >> "$GITHUB_ENV"

          echo "Prepared envs and CSP_POLICY (length: ${#CSP_POLICY} chars)."

      - name: Render ingress template
        run: |
          envsubst < ingress-template.yaml > processed-ingress.yaml
          echo "---- processed-ingress.yaml ----"
          sed -n '1,200p' processed-ingress.yaml

      ########################################################
      # Deploy options: kubeconfig | bastion | gitops
      ########################################################
      - name: Deploy (kubeconfig)
        if: env.DEPLOY_STRATEGY == 'kubeconfig'
        env:
          KUBECONFIG_B64: ${{ secrets.KUBECONFIG_B64 }}
        run: |
          echo "$KUBECONFIG_B64" | base64 -d > kubeconfig
          export KUBECONFIG="$PWD/kubeconfig"
          kubectl apply -f processed-ingress.yaml -n "${NAMESPACE}"
          kubectl get ingress -n "${NAMESPACE}" -o wide

      - name: Encode manifest for SSH deploy
        id: encode
        if: env.DEPLOY_STRATEGY == 'bastion'
        run: echo "fileb64=$(base64 -w 0 processed-ingress.yaml)" >> "$GITHUB_OUTPUT"

      - name: Deploy via Bastion/SSH
        if: env.DEPLOY_STRATEGY == 'bastion'
        uses: appleboy/ssh-action@master
        with:
          host: ${{ secrets.BASTION_HOST }}
          username: ${{ secrets.BASTION_USER }}
          key: ${{ secrets.BASTION_SSH_KEY }}
          script: |
            echo "${{ steps.encode.outputs.fileb64 }}" | base64 -d > /tmp/ingress.yaml
            kubectl apply -f /tmp/ingress.yaml -n "${{ github.event.inputs.environment }}"
            kubectl get ingress -n "${{ github.event.inputs.environment }}" -o wide

      - name: Commit processed manifest to a branch (GitOps)
        if: env.DEPLOY_STRATEGY == 'gitops'
        env:
          GIT_USER_EMAIL: "ci@example.com"
          GIT_USER_NAME: "CI Bot"
        run: |
          git config user.email "$GIT_USER_EMAIL"
          git config user.name "$GIT_USER_NAME"
          git checkout -b "ci/ingress-${{ github.run_id }}"
          mkdir -p out/${{ github.event.inputs.environment }}
          mv processed-ingress.yaml out/${{ github.event.inputs.environment }}/ingress-${{ github.event.inputs.host }}.yaml
          git add out/${{ github.event.inputs.environment }}/ingress-${{ github.event.inputs.host }}.yaml
          git commit -m "ci: update ingress for ${{ github.event.inputs.host }} (run ${{ github.run_id }})"
          git push --set-upstream origin "ci/ingress-${{ github.run_id }}"

What to set in repo vars / secrets

  • vars.BASE_CSP_POLICY — put the BASE_CSP_POLICY here (with ${ADDITIONAL_DOMAINS} placeholder).

  • vars.DEPLOY_STRATEGY — one of kubeconfig|bastion|gitops.

  • If using kubeconfig strategy: set secrets.KUBECONFIG_B64.

  • If using bastion strategy: set secrets.BASTION_HOST, secrets.BASTION_USER, secrets.BASTION_SSH_KEY.

  • If fetching from Vault/Infisical: store the auth tokens in secrets and implement the fetch step (examples placeholdered above).


8) Testing & verification

  • Quick curl check:
curl -sI <https://example.com/> | grep -Ei 'content-security-policy|strict-transport-security|x-frame-options|referrer-policy|x-content-type-options|permissions-policy'
  • Browser devtools → Network → Response headers.

  • Security scanners: SecurityHeaders.com, Mozilla Observatory.

  • CSP tuning: use Content-Security-Policy-Report-Only in staging to collect violations.


9) Best practices & pitfalls

  • Iterate CSP safely: Start in report-only to discover blocking rules.

  • Avoid unsafe-inline/unsafe-eval; prefer nonce/hash strategies with app-level cooperation.

  • Per-app overrides: Use per-Ingress annotations or app-level headers for exceptions — validate controller precedence.

  • Gate configuration-snippet in multi-tenant clusters — it allows arbitrary NGINX config.

  • TLS & HSTS: Only enable HSTS on HTTPS; automate certs with cert-manager.

  • Least privilege: Scope kubeconfig/CI tokens and ingress-controller RBAC tightly.

  • Audit & monitor: Ingress controller logs and metrics are crucial for detecting misconfigs and attacks.


10) Who recommends this approach?

Many cloud and enterprise docs (including Microsoft guidance and community posts) recommend placing security controls at the edge (load balancer / ingress / gateway) to reduce duplicated app-level configuration and centralize guardrails. This reduces human error and simplifies audits.


11) Next steps & follow-ups

  1. Add ingress-template.yaml to your repo.

  2. Add vars.BASE_CSP_POLICY (or place the policy in Vault/Infisical).

  3. Add the GitHub Actions workflow and choose a deploy strategy.

  4. Test in dev with report-only CSP, iterate based on violations, then enforce in staging/prod.

  5. Optionally integrate ExternalSecrets to sync secrets into the cluster or use GitOps for full auditability.


Why this matters

By applying CORS restrictions, strict headers, and fine-tuned ingress annotations, you’re not only protecting APIs but also improving performance and compliance. In practice, this can boost automated scanner scores (e.g., SSL Labs or Mozilla Observatory) from a F to at least an A rating, giving your services a stronger security posture with minimal effort.

Join the Conversation!

This is my first technical blog post – excited to start this journey and learn from the amazing Hashnode community!

Quick questions for you:

🔧 Current setup? How are you handling security headers right now? App-level, service mesh, or already using ingress?

🚨 CSP war stories? Ever had a Content Security Policy completely break your site? What was your "oh no" moment?

⚙️ Deployment preference? Which approach resonates with you - kubeconfig, bastion, or GitOps? Any battle-tested tips?

🎯 What's next? Since I'm passionate about all things tech (not just Kubernetes), what other security/DevOps topics would you like to see covered?

Drop a comment below - whether it's sharing experiences, asking questions, or just saying hello! As a new blogger, every comment helps me learn what resonates with the community.

Looking forward to connecting! 🚀

0
Subscribe to my newsletter

Read articles from Prateek Shetty directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Prateek Shetty
Prateek Shetty