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

Table of contents
- 1) Why centralize headers at the gateway?
- 2) High-level approach
- 3) Where to keep secrets & policies
- 4) Ingress template (ingress-template.yaml)
- 5) BASE_CSP_POLICY — suggested generic starting value
- 6) Render & apply locally (quick demo)
- 7) GitHub Actions workflow — full example (render, fetch secrets, deploy)
- 8) Testing & verification
- 9) Best practices & pitfalls
- 10) Who recommends this approach?
- 11) Next steps & follow-ups
- Join the Conversation!

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
Create a simple ingress manifest template that uses
${VARS}
so it’senvsubst
friendly.Store canonical policies (e.g.,
BASE_CSP_POLICY
) in repo variables or a secret manager.Use GitHub Actions to fetch secrets, render the template, and deploy to the cluster (kubeconfig, bastion, or GitOps).
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 hasallow-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 theBASE_CSP_POLICY
here (with${ADDITIONAL_DOMAINS}
placeholder).vars.DEPLOY_STRATEGY
— one ofkubeconfig|bastion|gitops
.If using
kubeconfig
strategy: setsecrets.KUBECONFIG_B64
.If using
bastion
strategy: setsecrets.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
; prefernonce
/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
Add
ingress-template.yaml
to your repo.Add
vars.BASE_CSP_POLICY
(or place the policy in Vault/Infisical).Add the GitHub Actions workflow and choose a deploy strategy.
Test in dev with
report-only
CSP, iterate based on violations, then enforce in staging/prod.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! 🚀
Subscribe to my newsletter
Read articles from Prateek Shetty directly inside your inbox. Subscribe to the newsletter, and don't miss out.
Written by
