# secureconfig.ai: all guides in one file > Generated from https://github.com/jposluns/secureconfig (CC0 1.0). > Per-guide index: https://secureconfig.ai/llms.txt ====================================================================== ==> README.md ====================================================================== # secureconfig Deployment exposure guides for AI-assisted projects: TLS, identity, MFA, secrets, and network controls for web apps, AI infrastructure, and the services behind them. Publicly reachable services built with AI assistance are routinely deployed on plain HTTP with no authentication. This repository holds short, generic, copy-ready guides that close that gap across the deployment surface: certificates and tunnels; human and machine identity, MFA, and secrets; web servers and proxies; language runtimes and frameworks; containers and Kubernetes; hosts, cloud firewalls, PaaS, and rented GPU clouds; databases, storage, and messaging; the AI stack itself (model servers, vector databases, MCP servers, agent and chat UIs, LLM observability); and the operational controls around a deployment (egress and metadata, exposed files, webhooks, and lifecycle from first run through teardown). The guides are written to be consumed by AI development assistants as well as by people: point your assistant at this repository and ask it to secure your project. The site at [secureconfig.ai](https://secureconfig.ai), served from [site/](site/) through Cloudflare Pages, is the short public pointer to these guides. ## Instructions for AI development assistants If you are an AI assistant configuring, generating, or reviewing a service, apply these rules: 1. Treat every service as internet-reachable unless you have confirmed otherwise. Bind services to `127.0.0.1` by default and expose them only through a TLS-terminating layer that requires authentication; keep backend, admin, and AI-tool services private. 2. Choose the certificate and access path before configuring exposure. A public DNS name with reachable ports 80 and 443: [free-certificates.md](free-certificates.md). A Cloudflare-managed domain with no inbound ports: [cloudflare.md](cloudflare.md). A tailnet with no public domain: [tailscale.md](tailscale.md). Internal or development use only: [self-signed.md](self-signed.md). 3. Apply every guide that fits the deployment, not just one. Select the service or framework, its host or platform, containers where used, plus identity, secrets, and the operational controls below. For a service with no guide, verify its native controls against the vendor documentation and use a private-access or fronting-layer pattern. 4. Redirect HTTP to HTTPS, or do not listen on HTTP at all. 5. Require authentication on every non-public endpoint. Never ship default or hardcoded credentials. Add MFA to human logins where viable per [mfa.md](mfa.md); where a tool has no native login, put one in front per [fronting-auth.md](fronting-auth.md). 6. A successful login is not authorisation. Check the returned identity against an allowlist (tenant, hosted domain, group, or explicit users) before granting access. Choose the provider per [identity-providers.md](identity-providers.md), wire it per [oidc-integration.md](oidc-integration.md), or put login in front of the app per [cloud-identity-proxies.md](cloud-identity-proxies.md). 7. Keep secrets out of the repository and load them from the environment or a secret manager per [secrets.md](secrets.md); give each service and agent its own scoped credentials per [machine-auth.md](machine-auth.md). 8. Contain what an exposed or compromised service can reach. Block unneeded outbound access and lock down cloud metadata per [egress-metadata.md](egress-metadata.md), and never serve dotfiles, database dumps, or client secrets per [web-exposure.md](web-exposure.md). 9. AI and data tools (model servers, vector databases, MCP servers, agent builders, chat and image UIs, MLflow, Ray) vary in their defaults and native controls, and many need authentication and TLS configured explicitly. Keep them on loopback and apply the matching guide before any exposure. 10. Run the verification checklist below and each selected guide's Verify steps before reporting the work as complete. Report any item you could not test instead of asserting that it passed. Fetch guides raw with `https://raw.githubusercontent.com/jposluns/secureconfig//.md` (for example `.../main/nginx.md`). Every guide concatenated into a single file: https://secureconfig.ai/llms-full.txt (also [site/llms-full.txt](site/llms-full.txt) in this repository); the machine-readable index is https://secureconfig.ai/llms.txt ## Decision guide Choose every route that applies; a deployment usually needs both service-specific guides and shared controls. - Public web app with its own domain: [free-certificates.md](free-certificates.md), then the guide for your web server or proxy, then [authentication.md](authentication.md). - App on a home server or behind NAT, with a domain you can put on Cloudflare: [cloudflare.md](cloudflare.md). The tunnel removes the need for open inbound ports and Access adds login in front of the app. With no domain at all: [tailscale.md](tailscale.md) (Serve for tailnet-only access, Funnel only with the app's own login), or [self-signed.md](self-signed.md) for internal use. - Internal tool, staging, or local development: [self-signed.md](self-signed.md), with authentication still enabled. - Human login or SSO: [identity-providers.md](identity-providers.md), [oidc-integration.md](oidc-integration.md) for app integration; [cloud-identity-proxies.md](cloud-identity-proxies.md) or [fronting-auth.md](fronting-auth.md) for login in front; [mfa.md](mfa.md) for the second factor, with an explicit access policy. - Service-to-service or agent access: [machine-auth.md](machine-auth.md), [secrets.md](secrets.md). - AI or agent deployment: the matching model-server, MCP, agent-builder, vector-database, UI, or observability guide, plus [gpu-clouds.md](gpu-clouds.md) where applicable and [egress-metadata.md](egress-metadata.md) for outbound and metadata. - Containers and clusters: [docker.md](docker.md), [container-hardening.md](container-hardening.md), [kubernetes.md](kubernetes.md), then the host, cloud, PaaS, or GPU guide. - Databases, caches, queues, and model servers: keep them off public interfaces entirely where possible; the per-tool guides cover TLS and authentication for the cases where network exposure is unavoidable. - Databases, storage, and messaging: the service guide, plus [object-storage.md](object-storage.md), [firebase-supabase.md](firebase-supabase.md), or [pocketbase.md](pocketbase.md) where access depends on storage or data rules. - Web application exposure: [web-exposure.md](web-exposure.md), [headers.md](headers.md), [cors.md](cors.md), [realtime-webhooks.md](realtime-webhooks.md). - First deployment, preview, or teardown: [deployment-lifecycle.md](deployment-lifecycle.md), then [common-mistakes.md](common-mistakes.md). ## Guide index ### Certificates and tunnels | Guide | Covers | |---|---| | [free-certificates.md](free-certificates.md) | Free publicly trusted certificates via ACME (Let's Encrypt, ZeroSSL), issuance, and automated renewal | | [self-signed.md](self-signed.md) | OpenSSL and mkcert certificates when a public CA is not an option, plus distributing trust to clients | | [cloudflare.md](cloudflare.md) | Cloudflare Tunnel and Zero Trust Access: authenticated external access with no open inbound ports | | [tailscale.md](tailscale.md) | Tailscale serve (tailnet-only) and funnel (public) with automatic TLS | | [tunnels.md](tunnels.md) | frp and WireGuard, self-hosted tunnels when there is no public IP | ### Identity, authentication, and secrets | Guide | Covers | |---|---| | [authentication.md](authentication.md) | Password storage, MFA, API keys, sessions, rate limiting, and secret handling | | [identity-providers.md](identity-providers.md) | Hosted identity and MFA: Entra ID, Google, Okta, Entra External ID, Firebase and Identity Platform, Auth0, Cognito, Clerk, WorkOS, Supabase Auth, Duo; free tiers and who each fits | | [oidc-integration.md](oidc-integration.md) | OIDC login wiring for Google, Microsoft Entra, GitHub, and Okta: PKCE, redirect URIs, token validation, and the allowlist check | | [cloud-identity-proxies.md](cloud-identity-proxies.md) | Login in front of the app with no code change: AWS ALB, Google IAP, Azure App Service, Cloudflare Access, ngrok, Vercel | | [fronting-auth.md](fronting-auth.md) | oauth2-proxy, Authelia, Pomerium: login and MFA in front of an app that has none | | [mfa.md](mfa.md) | MFA options: identity layers with QR-code TOTP enrolment, app libraries, SSH modules, Duo | | [machine-auth.md](machine-auth.md) | Machine identity: API keys, client credentials, mutual TLS, workload identity federation, secret managers | | [secrets.md](secrets.md) | Secrets: repository hygiene, scanning, rotation after a leak, sops/age | ### Web servers and proxies | Guide | Covers | |---|---| | [apache.md](apache.md) | Apache HTTP Server: TLS, redirect, HSTS, basic auth, client certificates | | [nginx.md](nginx.md) | nginx: TLS, redirect, HSTS, basic auth, client certificates, reverse proxy | | [lighttpd.md](lighttpd.md) | lighttpd: TLS via mod_openssl, redirect, basic auth | | [caddy.md](caddy.md) | Caddy: automatic HTTPS, internal CA, basic auth | | [haproxy.md](haproxy.md) | HAProxy: TLS termination, redirect, HSTS, basic auth | | [traefik.md](traefik.md) | Traefik: ACME resolvers, HTTPS redirection, basic auth middleware | ### Languages and frameworks | Guide | Covers | |---|---| | [nodejs.md](nodejs.md) | Node.js and Express: HTTPS server, security headers, sessions, password hashing | | [python.md](python.md) | Flask, FastAPI/Uvicorn, Gunicorn, Django: TLS options and secure settings | | [nextjs.md](nextjs.md) | Next.js: sessions, Route Handlers, Server Actions, Auth.js and Better Auth, Vercel | | [go.md](go.md) | Go net/http: TLS, proxy layout, bcrypt, cookies, client TLS discipline | | [dotnet.md](dotnet.md) | ASP.NET Core and Kestrel: HTTPS, HSTS, forwarded headers, Identity, cookies | | [java.md](java.md) | Spring Boot: server.ssl, forwarded headers, Spring Security, session cookies | | [php.md](php.md) | PHP and Laravel: password_hash, session cookies, trusted proxies, forced HTTPS | | [ruby.md](ruby.md) | Rails and Puma: force_ssl, trusted proxies, has_secure_password, credentials | | [frontend-frameworks.md](frontend-frameworks.md) | SvelteKit, Nuxt, Vite: server-route auth, sessions, public-env prefixes | ### Containers and Kubernetes | Guide | Covers | |---|---| | [docker.md](docker.md) | Docker and Compose: safe port publishing, the UFW bypass problem, TLS termination | | [kubernetes.md](kubernetes.md) | Gateway API with a maintained controller, cert-manager TLS, entry-point authentication; ingress-nginx is retired | | [container-hardening.md](container-hardening.md) | Non-root, dropped capabilities, read-only root, network segmentation | ### Hosts and cloud platforms | Guide | Covers | |---|---| | [host.md](host.md) | Host baseline: SSH hardening, firewall default-deny, brute-force protection, updates | | [cloud-firewalls.md](cloud-firewalls.md) | Security groups and VPC rules: no 0.0.0.0/0 on databases, SSH posture | | [paas.md](paas.md) | Render, Fly.io, Vercel, and similar: platform TLS, your auth and secrets | | [gpu-clouds.md](gpu-clouds.md) | RunPod, Vast.ai, Lambda, Modal: exposed ports and differing firewall and auth defaults per platform | ### Databases, storage, and messaging | Guide | Covers | |---|---| | [postgresql.md](postgresql.md) | PostgreSQL: server TLS, SCRAM authentication, pg_hba rules, verified client connections | | [mysql.md](mysql.md) | MySQL and MariaDB: required TLS transport, per-user TLS, modern auth plugins | | [mongodb.md](mongodb.md) | MongoDB: requireTLS, authorization, admin user creation, bind address | | [redis.md](redis.md) | Redis: TLS listener, ACLs, requirepass, bind and protected mode | | [sqlite.md](sqlite.md) | SQLite in deployment, the file is the exposure; Turso tokens; Litestream | | [surrealdb.md](surrealdb.md) | SurrealDB, root credentials, bind address, access levels, TLS | | [elasticsearch.md](elasticsearch.md) | Elasticsearch and OpenSearch: keep the built-in security on | | [clickhouse.md](clickhouse.md) | ClickHouse: user passwords, secure ports, network restrictions | | [neo4j.md](neo4j.md) | Neo4j: initial password, Bolt and HTTPS TLS, auth stays on | | [memcached.md](memcached.md) | Memcached: no auth by default; loopback, SASL and TLS where built in | | [search-engines.md](search-engines.md) | Meilisearch and Typesense, master key, scoped search keys, production mode | | [minio.md](minio.md) | MinIO: root credentials, certs directory TLS, scoped access keys | | [object-storage.md](object-storage.md) | S3, R2, GCS, Azure Blob, Supabase Storage: private by default, scoped credentials, signed URLs | | [firebase-supabase.md](firebase-supabase.md) | Firebase rules and Supabase RLS: the rules are the security | | [pocketbase.md](pocketbase.md) | PocketBase and Appwrite: the rules are the security; lock the admin console | | [rabbitmq.md](rabbitmq.md) | RabbitMQ: users and permissions, TLS listener, guest account | | [mosquitto.md](mosquitto.md) | Mosquitto (MQTT): per-device credentials, TLS listener, mutual TLS | | [kafka.md](kafka.md) | Apache Kafka: SASL_SSL listeners, SCRAM credentials, ACLs | | [nats.md](nats.md) | NATS and JetStream, auth, TLS, and the unauthenticated monitoring port | ### AI and agent infrastructure | Guide | Covers | |---|---| | [ollama.md](ollama.md) | Ollama: no built-in auth or TLS; protect it with a proxy or tunnel | | [model-servers.md](model-servers.md) | llama.cpp, vLLM, TGI, SGLang, Triton, LM Studio: loopback, API keys, TLS in front | | [litellm.md](litellm.md) | LiteLLM proxy: master key, per-app virtual keys | | [vector-databases.md](vector-databases.md) | Qdrant, Weaviate, Milvus, Chroma, pgvector: API keys, TLS, what has no native auth | | [mcp-servers.md](mcp-servers.md) | MCP servers: stdio versus Streamable HTTP, loopback, Origin checks, OAuth 2.1 or a fronting layer | | [ray.md](ray.md) | Ray: dashboard, Jobs, and Client ports execute code; isolate, token auth, SSH forward | | [mlflow.md](mlflow.md) | MLflow tracking server: no auth by default; basic-auth app, TLS in front | | [llm-observability.md](llm-observability.md) | Langfuse, Phoenix, Helicone, OpenTelemetry Collector: they hold prompts and keys | | [agent-builders.md](agent-builders.md) | Dify, Flowise, Langflow, LibreChat: admin setup, API keys, fronting TLS | | [workflow-orchestrators.md](workflow-orchestrators.md) | Prefect, Dagster, Airflow, Temporal, Flower: keep off the public internet and require auth | ### App UIs and dev tools | Guide | Covers | |---|---| | [open-webui.md](open-webui.md) | Open WebUI: signup control, pending role, fronting TLS | | [chat-uis.md](chat-uis.md) | AnythingLLM, LobeChat, Chainlit, OpenHands: open by default; front with login | | [image-gen-uis.md](image-gen-uis.md) | ComfyUI, A1111, InvokeAI, Fooocus: keep on loopback and add a login before exposing | | [gradio.md](gradio.md) | Gradio: launch() auth and TLS parameters, share link risks | | [streamlit.md](streamlit.md) | Streamlit: TLS options, native OIDC login, reverse proxy deployment | | [jupyter.md](jupyter.md) | Jupyter Server, Lab, and Notebook: hashed password and TLS | | [n8n.md](n8n.md) | n8n: listen address, native TLS, owner setup, MFA enforcement | | [code-server.md](code-server.md) | code-server: SSH forwarding first, password auth, TLS | ### Dashboards and admin consoles | Guide | Covers | |---|---| | [admin-uis.md](admin-uis.md) | phpMyAdmin, pgAdmin, mongo-express, Grafana, Prometheus: never public | | [devops-uis.md](devops-uis.md) | Portainer, Coolify, Dokploy, Nginx Proxy Manager, Vaultwarden, Kubernetes Dashboard, Jenkins, Gitea, Uptime Kuma, Docker API: never public | | [bi-dashboards.md](bi-dashboards.md) | Metabase, Superset, Redash: never public; least-privilege database user | ### Web application controls | Guide | Covers | |---|---| | [cors.md](cors.md) | CORS: exact origins, never * with credentials | | [headers.md](headers.md) | Security headers: HSTS, CSP, and companions for your app | | [realtime-webhooks.md](realtime-webhooks.md) | WebSocket, SSE, and webhook authentication | ### Exposure and deployment operations | Guide | Covers | |---|---| | [web-exposure.md](web-exposure.md) | Files a web server must never serve: dotfiles, .git, dumps, client secrets | | [egress-metadata.md](egress-metadata.md) | Egress control and cloud metadata (IMDSv2), stop an agent exfiltrating credentials | | [deployment-lifecycle.md](deployment-lifecycle.md) | Verify from outside, safe first-run order, previews, and teardown | | [common-mistakes.md](common-mistakes.md) | The recurring findings, each linked to its fix | ## Verification checklist Run the applicable checks below and the Verify steps in every selected guide. Record each result as pass, fail, untested, or not applicable, with evidence or a reason; report a control as verified only for the checks that passed. 1. No plaintext listener on a public interface: `ss -tlnp` (Linux) shows nothing bound to `0.0.0.0` or a public address on a plain HTTP port, except a listener whose only job is to redirect to HTTPS. 2. Where HTTP is offered, the redirect works: `curl -sI http://example.com/` returns `301` or `308` (the preferred permanent redirects), or `302`/`307` where a framework issues them, always with a `Location: https://...` header. 3. TLS works: `curl -sI https://example.com/` succeeds without `-k`. 4. Old protocols are refused: `openssl s_client -connect example.com:443 -tls1_1` fails to negotiate (TLS 1.2 is the minimum everywhere in these guides). 5. Authentication is enforced: an unauthenticated request to any non-public path returns `401`, `403`, or a login redirect, never data. Test the API paths as well as the home page. 6. No default credentials remain, and no secret (password, key, token, certificate private key) is committed to the repository. Scan before pushing, for example with gitleaks. 7. Where ACME is used, renewal is automated: `sudo certbot renew --dry-run` passes, or the server (Caddy, Traefik) manages renewal itself. 8. For public endpoints: scanned with the [Qualys SSL Labs test](https://www.ssllabs.com/ssltest/) or [testssl.sh](https://github.com/drwetter/testssl.sh). 9. Human-facing logins carry a second factor where the stack supports one; [mfa.md](mfa.md) lists the options, and the per-tool guides state what is viable. 10. Probe from outside the deployment network (a second host, or check your public IP on Shodan or Censys): cloud-firewall and Docker-publishing mistakes only show from outside. See [deployment-lifecycle.md](deployment-lifecycle.md). 11. Where login comes from an identity provider, an authenticated identity outside the allowed tenant, domain, group, or users is denied; test with such an account, not only an anonymous request. ## Scope and currency The guides use placeholders (`example.com`, `app.example.com`, `203.0.113.10`) that you must replace. Configuration syntax was checked against the vendor documentation cited in each guide as of September 2026; directives and dashboard menu locations change, so verify version-specific items against the current documentation for your installed version. Each guide lists its sources. Scope: deployment exposure, including TLS, human and machine authentication, MFA, access restrictions, secrets, and inbound and outbound network access, from first deployment through teardown. General application security, including injection, deserialization, and business logic flaws, belongs to the OWASP resources linked throughout the guides. To propose a tool or guide, see [CONTRIBUTING.md](CONTRIBUTING.md). ## Licence Everything in this repository (the guides, the configuration samples, and the site) is dedicated to the public domain under [CC0 1.0 Universal](LICENSE). Copy and reuse it freely; no attribution is required. ====================================================================== ==> free-certificates.md ====================================================================== # Free publicly trusted certificates (ACME) Publicly trusted certificates are free through ACME certificate authorities such as Let's Encrypt and ZeroSSL. Browsers and libraries accept them without any client-side configuration, which makes them the correct choice for every service with a public DNS name. Use [self-signed.md](self-signed.md) only when no public domain exists, or [cloudflare.md](cloudflare.md) when the host cannot accept inbound connections. ## Prerequisites - A DNS record (`A` or `AAAA`, or `CNAME`) for the hostname, pointing at the server. - For the HTTP-01 challenge: inbound port 80 reachable from the internet. - For the TLS-ALPN-01 challenge (used by Caddy and Traefik): inbound port 443. - For the DNS-01 challenge (required for wildcard certificates): API access to the DNS provider. If none of these is possible, use [cloudflare.md](cloudflare.md) instead. ## Certbot with Let's Encrypt Certbot is the reference ACME client. Install it from your distribution or via snap: ```bash # Debian/Ubuntu sudo apt install certbot python3-certbot-nginx python3-certbot-apache # Any distribution with snapd sudo snap install --classic certbot sudo ln -s /snap/bin/certbot /usr/bin/certbot ``` Issue and install in one step when certbot supports your web server: ```bash sudo certbot --nginx -d example.com -d www.example.com sudo certbot --apache -d example.com -d www.example.com ``` Issue only the certificate when you configure the server yourself, or when no web server is running yet: ```bash # Standalone: certbot binds port 80 itself; stop anything using it first sudo certbot certonly --standalone -d example.com # Webroot: the existing web server keeps running and serves the challenge files sudo certbot certonly --webroot -w /var/www/html -d example.com ``` Wildcard certificates require the DNS-01 challenge through a DNS plugin (for example `python3-certbot-dns-cloudflare`), with provider API credentials in a root-owned file: ```bash sudo certbot certonly --dns-cloudflare \ --dns-cloudflare-credentials /root/.secrets/cloudflare.ini \ -d example.com -d "*.example.com" ``` Certificates land in stable paths that server configuration should reference directly: ``` /etc/letsencrypt/live/example.com/fullchain.pem # certificate plus chain /etc/letsencrypt/live/example.com/privkey.pem # private key ``` ## Renewal Let's Encrypt certificates are valid for 90 days at the time of writing, so renewal must be automated. Package and snap installs of certbot register a systemd timer or cron job that runs `certbot renew` for you. Confirm that it works and reload the server after each renewal: ```bash sudo certbot renew --dry-run sudo certbot renew --deploy-hook "systemctl reload nginx" ``` Set the deploy hook once with `certonly`/`renew`, or drop a script into `/etc/letsencrypt/renewal-hooks/deploy/`. A certificate that issues once and then expires in production is the most common ACME failure; the dry run belongs in your deployment checklist. Lifetimes are getting shorter. Per Let's Encrypt as of September 2026: 6-day short-lived certificates are available now to every subscriber; the default `classic` profile moves to 64-day certificates on 2027-02-10 and to 45-day certificates on 2028-02-16; and industry rules cap publicly trusted certificates at 47 days from 2029-03-15. Any renewal step that involves a person will fail at those lifetimes, so the automation above is the only viable path. Let's Encrypt also switched off its OCSP service on 2025-08-06 and publishes revocation only through CRLs, so do not add OCSP stapling directives for Let's Encrypt certificates. ## Rate limits Let's Encrypt enforces per-domain issuance limits. Test against the staging environment (`certbot --staging` or `--test-cert`) until the configuration works, then issue the real certificate. Current limits: https://letsencrypt.org/docs/rate-limits/ ## CAA records A CAA DNS record restricts which certificate authorities may issue for your domain, limiting mis-issuance. Set it to the CA you use, for example `example.com. CAA 0 issue "letsencrypt.org"` for Let's Encrypt. Certificate-transparency monitoring detects mis-issuance after the fact; CAA constrains it beforehand, so treat the two as complementary, not as alternatives. ## Alternatives - **ZeroSSL**: free certificates over ACME. Some clients need External Account Binding (EAB) credentials from the ZeroSSL dashboard; the acme.sh client registers with ZeroSSL by default. - **acme.sh**: a dependency-light shell ACME client supporting many DNS providers, useful where certbot is unavailable. Install from the repository release rather than piping a downloaded script straight into a shell. https://github.com/acmesh-official/acme.sh - **Caddy and Traefik**: obtain and renew certificates themselves with no external client; see [caddy.md](caddy.md) and [traefik.md](traefik.md). This is the lowest-effort correct option for new deployments. - **Cloudflare origin certificates**: free and valid for long periods, but trusted only by Cloudflare's edge, so they are usable only behind the Cloudflare proxy; see [cloudflare.md](cloudflare.md). ## Verify ```bash sudo certbot certificates # what is issued and when it expires curl -sI https://example.com/ # succeeds without -k openssl s_client -connect example.com:443 -servername example.com self-signed.md ====================================================================== # Self-signed certificates Use a self-signed certificate when the service has no public DNS name, when an ACME CA cannot reach it, and when [cloudflare.md](cloudflare.md) is not an option: internal tools, lab and development environments, and machine-to-machine links on private networks. For anything a browser user or external party reaches, prefer [free-certificates.md](free-certificates.md); self-signed certificates trigger browser warnings and every client must be configured to trust them. Self-signed TLS still matters. It encrypts credentials and data in transit; without it, authentication tokens cross the network in cleartext. ## 1. Generate a certificate with OpenSSL RSA, single command (OpenSSL 1.1.1 or later for `-addext`): ```bash openssl req -x509 -newkey rsa:4096 -sha256 -days 365 -nodes \ -keyout server.key -out server.crt \ -subj "/CN=app.internal" \ -addext "subjectAltName=DNS:app.internal,DNS:localhost,IP:127.0.0.1,IP:203.0.113.10" ``` ECDSA (smaller and faster; generate the key first, then the certificate): ```bash openssl ecparam -name prime256v1 -genkey -noout -out server.key openssl req -x509 -key server.key -sha256 -days 365 -out server.crt \ -subj "/CN=app.internal" \ -addext "subjectAltName=DNS:app.internal,IP:203.0.113.10" ``` Rules that make the certificate actually work: - The `subjectAltName` list must contain every DNS name and IP address clients will use to reach the service. Modern clients validate SAN entries and ignore the CN. - `-nodes` leaves the key unencrypted so services can start unattended; protect it with file permissions instead. - Track the `-days` expiry. Nothing renews a self-signed certificate for you; put the date in your calendar or monitoring. ## 2. Protect the private key ```bash chmod 600 server.key chown server.key ``` Never commit a private key to version control. Add `*.key` and `*.pem` to `.gitignore` before generating anything inside a repository, and treat any key that has ever been committed or pasted into a chat as compromised: regenerate it. ## 3. mkcert for local development [mkcert](https://github.com/FiloSottile/mkcert) creates a local CA, installs it into your OS and browser trust stores, and issues certificates that your own machine trusts with no warnings: ```bash mkcert -install mkcert app.test localhost 127.0.0.1 ::1 ``` This is for development machines only. The generated CA can sign for any name, so its key must never leave the developer's machine, and mkcert certificates must never serve real users. ## 4. Make clients trust the certificate; never disable verification Distribute the certificate (or your internal CA certificate) to clients instead of turning verification off: ```bash # Debian/Ubuntu system trust store sudo cp server.crt /usr/local/share/ca-certificates/app-internal.crt sudo update-ca-certificates # RHEL/Fedora system trust store sudo cp server.crt /etc/pki/ca-trust/source/anchors/app-internal.crt sudo update-ca-trust # Per-tool curl --cacert server.crt https://app.internal/ export REQUESTS_CA_BUNDLE=/path/to/server.crt # Python requests export NODE_EXTRA_CA_CERTS=/path/to/server.crt # Node.js ``` Do not ship `curl -k`, `verify=False`, `rejectUnauthorized: false`, or `NODE_TLS_REJECT_UNAUTHORIZED=0` in committed code. Each of these disables TLS validation entirely, for attacker-controlled certificates as much as for your own, and they reliably survive into production. ## 5. Verify ```bash openssl x509 -in server.crt -noout -subject -dates -ext subjectAltName openssl s_client -connect app.internal:443 -servername app.internal -verify_hostname app.internal -verify_return_error -CAfile server.crt cloudflare.md ====================================================================== # Cloudflare Tunnel and Zero Trust Access This is the recommended path when the host cannot or should not accept inbound connections: home labs, NATed machines, cloud VMs you want to keep closed, and any project without its own TLS setup. `cloudflared` opens an outbound-only tunnel to Cloudflare's edge, the edge serves your hostname over HTTPS with a Cloudflare-managed certificate, and Cloudflare Access places authentication (SSO or emailed one-time PIN) in front of the app without any application changes. No inbound firewall ports are opened at all. ## 1. Prerequisites - A domain added to Cloudflare (the free plan is sufficient), with Cloudflare as its DNS. - A Zero Trust organization on the account. At the time of writing the free tier covers up to 50 users; verify current limits at https://www.cloudflare.com/plans/zero-trust-services/ - `cloudflared` installed on the host that can reach the service (packages for Linux, macOS, and Windows: https://github.com/cloudflare/cloudflared). ## 2. Create the tunnel (dashboard-managed, recommended) Per the Cloudflare docs as of June 2026 (menu locations change; the sources below are authoritative): 1. In the Cloudflare dashboard go to **Networking > Tunnels** and create a tunnel (connector type `cloudflared`). 2. Copy the installation command the dashboard shows for your OS and run it on the host. It embeds a tunnel token and installs `cloudflared` as a service (`cloudflared service install ` on Linux). 3. Add a route: **Routes > Add route > Published application**, choose the subdomain (for example `app.example.com`), and set the service URL to the local service, for example `http://localhost:3000`. The app is now reachable at `https://app.example.com` over TLS terminated at Cloudflare's edge. Traffic between `cloudflared` and Cloudflare travels inside the encrypted tunnel; the `http://localhost:3000` hop stays on the host itself. ## 3. CLI alternative (locally-managed tunnel) ```bash cloudflared tunnel login cloudflared tunnel create myapp cloudflared tunnel route dns myapp app.example.com ``` `~/.cloudflared/config.yml`: ```yaml tunnel: credentials-file: /home/user/.cloudflared/.json ingress: - hostname: app.example.com service: http://localhost:3000 - service: http_status:404 ``` Run with `cloudflared tunnel run myapp`, or install it as a service with `sudo cloudflared service install`. The credentials JSON and the tunnel token are secrets: they let anyone publish services on your hostname, so keep them out of repositories. ## 4. Add authentication with Access A tunnel publishes the app; Access is what makes it authenticated. In the Zero Trust dashboard: 1. Go to the **Access > Applications** section and add a **self-hosted** application for `app.example.com`. 2. Create an **Allow** policy. Sensible starting rules: `Emails` listing specific addresses, or `Emails ending in` your domain. 3. Choose login methods. The built-in **One-time PIN** (a code emailed to the allowed address) works with zero identity-provider setup; connect Google, GitHub, Microsoft Entra ID, or another IdP for SSO and MFA. Every request to the hostname now hits a Cloudflare login page first; only identities matching the policy reach the app. MFA: the emailed one-time PIN proves control of a mailbox only. For anything sensitive, connect an identity provider and enforce MFA there; Access then inherits it. Broader options: [mfa.md](mfa.md). For APIs and machine clients, create a **service token** in the Zero Trust dashboard (Access service authentication section), add a **Service Auth** policy to the application, and send the token with each request: ```bash curl -H "CF-Access-Client-Id: " \ -H "CF-Access-Client-Secret: " \ https://app.example.com/api ``` ## 5. Close the side doors - Bind the application to `127.0.0.1` so the tunnel is the only path to it. If the app also listens publicly, Access is decorative. - Do not use quick tunnels (`cloudflared tunnel --url http://localhost:3000`, the random `trycloudflare.com` URLs) for anything real: they are unauthenticated and intended for short-lived testing. - If the origin must sit on a different machine from `cloudflared`, run TLS on that hop too (`service: https://...`; see [self-signed.md](self-signed.md)). - Related but distinct: for a directly-exposed origin behind Cloudflare's proxy (no tunnel), install a free **Cloudflare origin certificate** on the server and set the zone's TLS mode to **Full (strict)**. Origin certificates are trusted only by Cloudflare's edge, never by browsers directly. ## 6. Verify - A private-browsing visit to `https://app.example.com` shows the Access login page, not the app. - `curl -sI https://app.example.com/` returns a redirect to the Access login, not application content. - With a service token, the same request returns application content. - `ss -tlnp` on the host shows the app bound to `127.0.0.1` only, and your firewall shows no inbound rule added for it. ## Sources (checked September 2026) - Cloudflare Zero Trust documentation: https://developers.cloudflare.com/cloudflare-one/ - Create a remotely-managed tunnel: https://developers.cloudflare.com/cloudflare-one/networks/connectors/cloudflare-tunnel/get-started/create-remote-tunnel/ - cloudflared releases: https://github.com/cloudflare/cloudflared ====================================================================== ==> tailscale.md ====================================================================== # Tailscale: serve and funnel Tailscale gives the same no-open-inbound-ports posture as [cloudflare.md](cloudflare.md), built on WireGuard with device identity as the access control. Two commands matter, and they differ in exactly one thing: who can reach the service. ## 1. tailscale serve: tailnet-only (authenticated by membership) ```bash tailscale serve --bg localhost:3000 ``` - Reachable only by devices in your tailnet, so access is authenticated by device identity and your tailnet ACLs. - HTTPS uses an automatically provisioned TLS certificate for the machine's tailnet name. - `--bg` keeps it running in the background; without it, the share stops with the session. This is the right default for admin panels, dashboards, Jupyter, and internal tools: no certificate work, no public exposure at all. ## 2. tailscale funnel: public internet (bring your own auth) ```bash tailscale funnel 3000 ``` - Publishes the service to the entire internet at your `*.ts.net` hostname, TLS included. - Funnel itself adds **no per-request authentication**; the relay does not even decrypt your traffic. Anything funneled needs application-level login per [authentication.md](authentication.md) and, for human logins, [mfa.md](mfa.md), exactly as if it sat behind any public proxy. - Prerequisites per the docs: HTTPS certificates enabled for the tailnet, a `funnel` node attribute in the tailnet policy file, and MagicDNS. ## 3. Choosing between them Serve for anything private (most things). Funnel or [cloudflare.md](cloudflare.md) for genuinely public services; Cloudflare Access adds managed login in front, which funnel does not, so prefer Access when the public service is for a defined set of people. Command syntax changed in Tailscale v1.52; on older clients consult `tailscale serve --help`. ## 4. Verify ```bash tailscale serve status curl -sI https://..ts.net/ # from a tailnet device: works # From a non-tailnet network: serve URL unreachable; funnel URL reachable, so its app login must gate it. ``` ## Sources (checked September 2026) - Tailscale serve: https://tailscale.com/kb/1242/tailscale-serve - Tailscale funnel: https://tailscale.com/kb/1223/funnel ====================================================================== ==> tunnels.md ====================================================================== # Self-hosted tunnels: frp and WireGuard Both expose a private host to the internet without a public IP, the same job [cloudflare.md](cloudflare.md) and [tailscale.md](tailscale.md) do, but with no vendor edge: you run and secure both ends yourself, on a host still hardened per [host.md](host.md). frp with a weak or absent token lets anyone bind proxies through your server; WireGuard has no login at all, only key pairs and the traffic scoping you configure. ## frp `frps` (the server) listens for client connections on `bindPort`, default `7000`. Authentication is token-based by default: set the identical `auth.token` in `frps.toml` and every `frpc.toml`, since "client needs to set the same value to pass authentication": ```toml # frps.toml bindPort = 7000 auth.token = "REPLACE_WITH_LONG_RANDOM_VALUE" ``` ```toml # frpc.toml serverAddr = "203.0.113.10" serverPort = 7000 auth.token = "REPLACE_WITH_LONG_RANDOM_VALUE" ``` frp also supports `auth.method = "oidc"`, authenticating both sides against an OIDC provider's Client Credentials Grant instead of a shared token, useful for centralizing frp auth behind an identity provider. From frp v0.50.0, `transport.tls.enable` defaults to `true`, so the connection between `frpc` and `frps` is encrypted out of the box; the gap is verification, not encryption. `frpc` still does not verify `frps`'s certificate by default, so it will encrypt to whatever server answers on `serverAddr`, genuine or not. Set `transport.tls.certFile`/`transport.tls.keyFile` on the server and `transport.tls.trustedCaFile` on every client so `frpc` verifies the server's certificate against a trusted CA, and set `transport.tls.force = true` on the server so it refuses any client that did not negotiate TLS at all. The `auth` block is schema-optional, and the frp documentation does not state what a server does when it is left out entirely. Treat an unconfigured token as an open door rather than assuming a safe default: always set `auth.token` (or OIDC) before exposing `bindPort` to the internet, and never rely on frp for anything without one. ## WireGuard WireGuard has no username or password; identity is a base64-encoded key pair, generated per peer: ```bash umask 077 wg genkey | tee privatekey | wg pubkey > publickey ``` The private key never leaves the peer that generated it; only the public key goes into the other side's configuration. A peer is added to an interface with its public key, an endpoint, and `AllowedIPs`: ```bash wg set wg0 listen-port 51820 private-key /path/to/private-key peer "REPLACE_WITH_PEER_PUBLIC_KEY" allowed-ips 192.168.88.0/24 endpoint 203.0.113.10:51820 ``` `AllowedIPs` is dual-purpose "Cryptokey Routing," but the two purposes are not symmetric. On the sending side it "behaves as a sort of routing table," picking which peer a destination IP goes to. On the receiving side it "behaves as a sort of access control list" for the packet's source address only, dropping a decrypted packet whose source IP does not match the sending peer's configured `AllowedIPs`; it is a spoofing check, not a destination filter, and it is the server's actual restriction on which source address a peer may use. Once a peer is authenticated, its `AllowedIPs` entry places no limit on which destinations that peer is allowed to reach if the server is willing to forward the traffic there. Scope `AllowedIPs` to exactly the address or subnet a peer should be reached at, never `0.0.0.0/0` unless that peer is genuinely meant to be a full-tunnel gateway, and restrict which destinations a peer can reach through the server with a firewall rule on the server itself, matching the same source prefix as that peer's `AllowedIPs` so the peer cannot rotate its source address within that prefix to evade a narrower rule, for example an nftables rule dropping forwarded packets from anywhere in this peer's permitted subnet to a destination outside its intended subnet, with a counter so the rule's effect can be confirmed later: ```bash nft add rule inet filter forward iifname "wg0" ip saddr 192.168.88.0/24 ip daddr != 192.168.88.0/24 counter drop ``` By default WireGuard "tries to be as silent as possible when not being used," so the only inbound port a firewall needs to open is the single WireGuard UDP `ListenPort`; everything else on the host stays closed per [host.md](host.md). ## Verify ```bash # frp: a client with the wrong token is rejected, not connected frpc -c frpc-wrongtoken.toml # expect an authentication failure, no proxy registered ss -ulnp | grep 51820 # WireGuard: only the one UDP port listening sudo ufw status verbose # no other inbound rule added for the tunneled service # positive control: from the peer, a destination inside its intended subnet must succeed, proving # the tunnel and routing both work ping -c1 192.168.88.10 # forbidden destination: from the peer, a host the server would otherwise forward to (its own LAN, # reachable through the tunnel if not for this rule) but outside 192.168.88.0/24, so a failure here # is attributable to the firewall rule rather than to an address that was never routed or never live ping -c1 192.168.1.50 # expect 100 percent packet loss # confirm the drop is the firewall rule acting, not a routing gap: its counter must be nonzero sudo nft list ruleset | grep -A1 'saddr 192.168.88.0/24' # packets and bytes both greater than 0 ``` ## Sources (checked September 2026) - frp documentation (setup, server reference): https://gofrp.org/en/docs/ - frp server configuration reference (`bindPort`, `auth.token`, `transport.tls.force`): https://gofrp.org/en/docs/reference/server-configures/ - frp authentication (`auth.token`, `auth.method = "oidc"`): https://gofrp.org/en/docs/features/common/authentication/ - WireGuard quickstart (`wg genkey`, `wg pubkey`, `wg set`, silent-protocol behavior): https://www.wireguard.com/quickstart/ - WireGuard Cryptokey Routing (`AllowedIPs` on send and receive): https://www.wireguard.com/ ====================================================================== ==> authentication.md ====================================================================== # Strong authentication baseline TLS without authentication leaves a service open to the whole internet over an encrypted channel. These rules apply to every service in this repository's guides. `must` marks a requirement; `should` marks a recommendation. ## Rules 1. **Deny by default.** Every endpoint that is not deliberately public must require authentication, including APIs, health dashboards, admin panels, metrics, and message queues. Publish an explicit list of the paths that are public; everything else authenticates. 2. **No default or shared credentials.** Change or disable every vendor default account before exposure. Each human gets an individual account; each service gets its own credential. Never ship credentials in code, containers, or documentation. 3. **TLS first.** Credentials must only cross the network inside TLS. HTTP basic authentication and bearer tokens are acceptable only over HTTPS, because both send the secret with every request. 4. **Hash passwords with a modern algorithm.** Store only argon2id or bcrypt hashes (per current OWASP guidance; scrypt and correctly parameterized PBKDF2 are also acceptable). Never store plaintext, and never use unsalted or fast hashes such as MD5 or SHA-256 for passwords. - Node.js: `bcrypt` or `argon2` packages. - Python: `argon2-cffi` or `bcrypt`. - Shell (for htpasswd files): `htpasswd -B` (bcrypt). 5. **Generate secrets randomly and keep them out of the repository.** ```bash openssl rand -base64 32 python3 -c "import secrets; print(secrets.token_urlsafe(32))" ``` Load secrets from environment variables or a secret manager. Add `.env` to `.gitignore` before the first commit, and scan the repository for leaked secrets (for example with gitleaks) before pushing. A secret that has reached a public repository, a chat, or a log is compromised: rotate it, since deleting the file does not unpublish it. 6. **Prefer SSO/OIDC over local accounts** where the product supports it, and enable MFA wherever available. [Cloudflare Access](cloudflare.md) puts SSO or one-time-PIN login in front of any web app without changing the app. Where no native MFA exists, add it with an identity layer, an app-level TOTP library, or a hosted service, per [mfa.md](mfa.md). 7. **Scope machine access.** API clients get their own tokens with the least privilege the task needs, not an admin password. Support and exercise rotation; set expiry where the platform allows it. 8. **Harden sessions.** Set cookies `Secure`, `HttpOnly`, and `SameSite` (`Lax` or `Strict`), sign them with a strong random secret, and expire them. Invalidate sessions on password change. 9. **Rate-limit authentication endpoints** and lock or delay after repeated failures. Log authentication successes and failures with source address and account, and keep the logs long enough to investigate an incident. fail2ban is a low-effort control for SSH and login panels on Linux hosts. Bound expensive endpoints too: inference, uploads, and job submission need request-size, concurrency, and timeout limits in addition to per-client rate limits, because an exposed AI endpoint left without them can burn GPU time and money even while correctly rejecting bad credentials, a failure mode known as denial of wallet. [realtime-webhooks.md](realtime-webhooks.md) covers the streaming and webhook transports these limits also apply to. 10. **Least privilege everywhere.** Separate admin from daily-use accounts, and give database and OS service accounts only the rights the application uses. 11. **Federated login is authentication, not authorisation.** After Google, Microsoft, GitHub, or any provider returns an identity, check it against an allowlist (tenant, hosted domain from the verified token claim, organisation or group membership, or explicit users) before granting access. Any Google account is not "staff", and Microsoft's multi-tenant `common` endpoint admits every Microsoft account unless the app validates the issuer and tenant. [oidc-integration.md](oidc-integration.md) has the checks; [identity-providers.md](identity-providers.md) has the providers. 12. **OIDC and OAuth hygiene.** Authorization code flow with PKCE; exact-match redirect URIs; `state` and `nonce` verified; ID tokens validated for signature (keys from the provider's JWKS, algorithm pinned, never `none`), issuer, audience, and expiry; short-lived access tokens with refresh-token rotation; tokens never in URLs. Prefer a server-side session in an `HttpOnly` cookie to tokens in browser storage. Link accounts by issuer plus subject, never by email alone. 13. **Enforce MFA where access is granted, not only where it is enrolled.** A user who enrolled a second factor but can still act with a password-only session is not protected. Require the factor in provider policy or in the app, and test it. 14. **Protect the control plane.** MFA on the Git host, the cloud account, the DNS registrar, the deployment platform, the secret manager, and the identity provider's administrator account. A takeover there bypasses every control inside the app. 15. **Authenticate every transport.** WebSockets, server-sent events, GraphQL, gRPC, webhooks (verify the sender's signature and reject replays), inference endpoints, and management APIs each need their own check. A login on the HTML pages protects none of them. Machine credentials follow [machine-auth.md](machine-auth.md). 16. **Offboard promptly.** When a person leaves, revoke their provider membership, proxy sessions, application sessions, and personal tokens. Know your maximum time-to-revoke for each system where immediate revocation is not available, and treat that number as something to shrink, not a fact to accept. [deployment-lifecycle.md](deployment-lifecycle.md) has the lifecycle checks; [mfa.md](mfa.md) covers revoking the second factor along with the account. ## Quick checks - Unauthenticated `curl` against a protected path returns `401`, `403`, or a login redirect, never data. - `git log -p | grep -iE 'password|secret|api[_-]?key'` over a new repository comes back empty (a scanner does this better; use one). - The user store contains no account named `admin`, `test`, or `demo` with a known or empty password. - Negative tests pass: a missing, expired, wrong-issuer, wrong-audience, or wrong-tenant token is rejected; user A cannot read user B's resources; the origin is unreachable except through its fronting layer. ## Sources (checked September 2026) - OWASP Authentication Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html - OWASP Password Storage Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Password_Storage_Cheat_Sheet.html - OAuth 2.0 Security Best Current Practice (RFC 9700): https://www.rfc-editor.org/rfc/rfc9700.html - OpenID Connect Core 1.0: https://openid.net/specs/openid-connect-core-1_0.html ====================================================================== ==> mfa.md ====================================================================== # Multi-factor authentication (MFA) Passwords fail through phishing, reuse, and credential stuffing; a second factor keeps a stolen password from becoming access. This guide names the options and the references; it deliberately stops short of per-product walkthroughs, because an AI assistant that knows which option fits can implement it from the linked project documentation. The per-tool guides in this repository state what is viable for each stack. ## What to do (AI assistants) 1. Give every human-facing login on an exposed service a second factor where viable. 2. Prefer, in this order: 1. Platform-native MFA, or OIDC/SSO login with MFA enforced at the identity provider. 2. An identity-aware layer in front of the app (Cloudflare Access, or a self-hosted portal below), which adds MFA without changing the app, but only when the layer's own policy, or the identity provider it delegates to, requires a second factor. 3. App-level TOTP through a library (below). 4. A hosted MFA service such as Duo. 3. Prefer phishing-resistant factors (WebAuthn/passkeys) over TOTP where the platform offers them, and TOTP over emailed or SMS codes. 4. When implementing TOTP yourself, the required pieces are: a random per-user secret; an `otpauth://` provisioning URI rendered as a QR code for the user's authenticator app; verification of 1 valid code before the factor activates; single-use recovery codes (stored hashed); rate limiting on code attempts; rejection of a code that has already been accepted for its time step (RFC 6238 forbids accepting a code twice); and TOTP secrets encrypted at rest and excluded from the repository (they cannot be hashed, since the server must read them to verify codes). 5. Give administrators a phishing-resistant factor (a passkey or a FIDO2 hardware key) wherever the platform offers one. TOTP is the floor; SMS is not acceptable for administrator accounts. 6. Enrolment is not enforcement. After users enrol, require the factor at the point of access (provider policy such as Conditional Access, Supabase `aal2` in policies, or the app's own check) and test that a password-only session is refused. ## Identity layers (open source, QR-code TOTP enrolment built in) - **Authelia**: authentication portal that sits in front of a reverse proxy; per its support matrix it integrates with nginx (`auth_request`), Traefik (`forwardAuth`), Caddy (`forward_auth`, 2.5.1 and later), HAProxy (through a Lua module), and Envoy, while Apache and IIS are documented as unsupported. Second factors: TOTP, WebAuthn/passkeys, and mobile push. https://www.authelia.com/ - **authentik**: self-hosted identity provider (OIDC and SAML) with TOTP and WebAuthn factors; apps behind it inherit its MFA. https://goauthentik.io/ - **Keycloak**: full OIDC/SAML identity provider with built-in OTP enrolment; the standard choice when you also need user federation and roles. https://www.keycloak.org/ - **oauth2-proxy**: puts any upstream behind an OIDC/OAuth2 provider; MFA is whatever that provider enforces. https://github.com/oauth2-proxy/oauth2-proxy ## App-level TOTP libraries Each generates and verifies RFC 6238 codes and pairs with a QR library so users can enrol any authenticator app (Google Authenticator, Microsoft Authenticator, Aegis, FreeOTP, and password managers with TOTP support). - Python: [pyotp](https://github.com/pyauth/pyotp) with [qrcode](https://pypi.org/project/qrcode/); [django-otp](https://pypi.org/project/django-otp/) integrates this into Django. - Node.js: [otplib](https://github.com/yeojz/otplib) with [qrcode](https://www.npmjs.com/package/qrcode). - Go: [pquerna/otp](https://github.com/pquerna/otp), which includes QR image generation. ## SSH and host logins - [google-authenticator-libpam](https://github.com/google/google-authenticator-libpam): PAM module adding per-user TOTP to SSH and console logins, with QR enrolment in the terminal (`libpam-google-authenticator` package on Debian/Ubuntu). - Duo Unix (`pam_duo`) adds push-approval MFA to SSH: https://duo.com/docs/duounix ## Hosted MFA - **Hosted identity providers** (Microsoft Entra ID, Google Workspace, Auth0, Amazon Cognito, Clerk, Supabase Auth, and others) can enforce MFA for every app that signs in through them, but only when a tenant policy requires the factor; enrolment or a default policy alone adds nothing. Which tiers include MFA, and which do not, is in [identity-providers.md](identity-providers.md); wiring is in [oidc-integration.md](oidc-integration.md). - **Duo**: the Duo Free edition covers up to 10 users with MFA and the Duo Mobile authenticator app (per https://duo.com/editions-and-pricing as of September 2026; verify current terms). Its Authentication Proxy speaks RADIUS and LDAP, which retrofits MFA onto VPNs and onto services with RADIUS support. - **Cloudflare Access** ([cloudflare.md](cloudflare.md)): the emailed one-time PIN proves control of a mailbox only; for sensitive apps connect an identity provider and enforce MFA there; Access inherits MFA only when that provider's policy requires it for the login. Enforcing MFA once at a central identity provider is easier to operate and audit than separate factors per app; prefer it when more than 1 service is involved. ## Passkeys and hardware keys WebAuthn passkeys and FIDO2 hardware keys (YubiKey and similar) resist phishing because the credential is bound to the site's origin; a look-alike domain gets nothing. Every hosted provider in [identity-providers.md](identity-providers.md) offers them at some tier. When implementing them yourself, use a maintained WebAuthn library rather than parsing attestation by hand, store the credential public key and sign count, and keep a recovery path (a second key or single-use recovery codes) so a lost key is not a lockout. ## Where direct MFA is not viable Machine protocols (database wire protocols, model-server APIs) have no interactive second-factor dialogue. There the pattern is: mutual TLS client certificates as the possession factor for the service itself, and MFA on every human path that reaches the host (SSH, bastions, admin panels). The database guides in this repository apply this pattern. ## Verify - A login with only the password fails once a second factor is enrolled. - Recovery codes are single-use, and their hashes rather than their values are stored. - Repeated wrong codes hit a rate limit or lockout. - Submitting the same valid TOTP code a second time within its time step is refused. - No TOTP secret or recovery code appears in the repository or its history. - A departed user's second factor, active sessions, and app-level access are revoked, not only their password changed. Enforcing MFA is not the end; offboarding belongs in the access lifecycle too, and [deployment-lifecycle.md](deployment-lifecycle.md) has the checklist. ## Standards and sources (checked September 2026) - TOTP: https://www.rfc-editor.org/rfc/rfc6238 ; HOTP: https://www.rfc-editor.org/rfc/rfc4226 - `otpauth://` key URI format: https://github.com/google/google-authenticator/wiki/Key-Uri-Format - WebAuthn: https://www.w3.org/TR/webauthn-2/ - Authelia proxy support matrix: https://www.authelia.com/integration/proxies/support/ - Duo editions and pricing: https://duo.com/editions-and-pricing ====================================================================== ==> secrets.md ====================================================================== # Secrets: keeping keys out of repositories Leaked API keys and credentials in public repositories are the most common security incident in AI-assisted projects, and scanners harvest fresh commits within minutes. [authentication.md](authentication.md) states the baseline; this guide covers the handling. ## Rules 1. **Secrets never enter version control.** Add `.env`, `*.key`, and `*.pem` to `.gitignore` before the first commit. Load secrets from environment variables or a secret manager (AWS Secrets Manager, Google Secret Manager, Azure Key Vault, or your platform's store per [paas.md](paas.md)). 2. **Secrets never enter images or build logs.** `ENV` and `ARG` values in a Dockerfile ship with the image and appear in `docker history`; pass secrets at runtime instead ([docker.md](docker.md)). Do not print secrets in application or CI logs. 3. **Generate secrets randomly** (`openssl rand -base64 32`; `python3 -c "import secrets; print(secrets.token_urlsafe(32))"`), 1 per service and environment, never shared between staging and production. 4. **Scan before every push.** [gitleaks](https://github.com/gitleaks/gitleaks) or [trufflehog](https://github.com/trufflesecurity/trufflehog) as a pre-commit hook and in CI: ```bash gitleaks git . # scans the repository history gitleaks dir . # scans the working tree ``` 5. **CI/CD secrets live in the platform's secret store** (for example GitHub Actions secrets), scoped to the jobs that need them, never echoed into logs or artefacts. ## When a secret leaks Order matters: 1. **Rotate first.** Revoke the exposed credential at its provider and issue a new one. A secret that reached a public repository, a chat, a log, or a paste is compromised even if deleted seconds later; scrapers and forks already have it. 2. Only then clean the history if required (for example with [git-filter-repo](https://github.com/newren/git-filter-repo)), understanding that cleaning is hygiene, never containment: it does not unpublish anything. 3. Check provider logs for use of the leaked credential during the exposure window. ## Encrypting secrets that must be versioned When a team needs configuration secrets in git (for example GitOps deployments), encrypt them: [sops](https://github.com/getsops/sops) with [age](https://github.com/FiloSottile/age) keys encrypts the values inside YAML/JSON/ENV files while leaving the structure diffable. The decryption key itself stays out of the repository. ## Verify ```bash gitleaks git . && echo clean grep -rn "sk-\|AKIA\|-----BEGIN" --include="*.py" --include="*.js" --include="*.ts" --include="*.env" . | grep -v node_modules # crude but fast ``` On every push, gitleaks must exit 0 with no findings (the `&& echo clean` then prints `clean`), and the grep must print nothing. ## Sources (checked September 2026) - OWASP Secrets Management Cheat Sheet: https://cheatsheetseries.owasp.org/cheatsheets/Secrets_Management_Cheat_Sheet.html - gitleaks: https://github.com/gitleaks/gitleaks - trufflehog: https://github.com/trufflesecurity/trufflehog - sops: https://github.com/getsops/sops and age: https://github.com/FiloSottile/age - git-filter-repo: https://github.com/newren/git-filter-repo ====================================================================== ==> identity-providers.md ====================================================================== # Identity providers: hosted login and MFA for your app and your team [authentication.md](authentication.md) says to prefer SSO or OIDC over local accounts and to enforce MFA at the identity provider. This guide names the providers, states what their free tiers include, and says who each fits. Wiring instructions live in [oidc-integration.md](oidc-integration.md); login placed in front of an app without code changes lives in [cloud-identity-proxies.md](cloud-identity-proxies.md) and [cloudflare.md](cloudflare.md). Every tier and price below was read from the vendor's pricing page in September 2026 and will change; verify against the linked source before relying on it. Prices are USD list prices. ## 1. Decide which kind of identity you need - **Workforce identity**: your team signs in with the organisation's existing accounts (Google Workspace, Microsoft Entra ID, Okta). Use it for admin panels, dashboards, internal tools, and anything only staff should reach. If your organisation already runs one of these, use it; do not buy a second directory. - **Customer identity (CIAM)**: your application's own users sign up and sign in. Use a hosted provider rather than writing password storage, MFA, recovery, and rate limiting yourself. - **Developer identity**: GitHub (or GitLab) login gates a tool for developers, optionally restricted to an organisation. Often the simplest correct choice for a solo or small project. A login proves who the person is. **It does not decide whether they may use your app.** After any of the providers below, your application (or the fronting layer) still checks that the identity is on an allowlist: a tenant, a hosted domain, a group, or an explicit list of users. Accepting every Google or Microsoft account as "staff" is the recurring mistake; [oidc-integration.md](oidc-integration.md) covers the check. ## 2. Workforce providers | Provider | Free tier and MFA facts (September 2026) | Fit | |---|---|---| | Microsoft Entra ID | Free tier, bundled with Azure and Microsoft 365 subscriptions, includes MFA and unlimited SSO to SaaS apps. Security defaults require every user to register MFA, enforce it for administrators, and prompt other users when Microsoft judges it necessary. P1 at $7 per user per month (annual commitment) adds Conditional Access, which is how you require MFA for everyone on every sign-in; P2 at $10 adds risk-based policies. | Any team already on Microsoft 365. | | Google Workspace / Cloud Identity | Sign in with Google (OIDC) costs nothing per app. Enforcing 2-Step Verification for the whole organisation is an admin-console setting in Workspace or Cloud Identity. Cloud Identity Free exists; its default licence count was not read from a Google page for this guide, so check the source. | Any team already on Workspace. | | Okta Workforce Identity | Okta Verify supports push, TOTP, and FastPass. A $1,500 annual contract minimum applies and there is no free production tier. | Only when your organisation already runs Okta. Not a purchase for a small project. | | JumpCloud, OneLogin, Ping Identity | Workforce directories in the same class. Tiers not verified for this guide. | Existing enterprise estates only. | ## 3. Customer identity providers | Provider | Free tier and MFA facts (September 2026) | Notes | |---|---|---| | Microsoft Entra External ID | Core features free for the first 50,000 monthly active users (MAU). SMS MFA and machine-to-machine (client credentials) authentication are billed per transaction as add-ons. | Keep the external (customer) tenant separate from the workforce tenant. | | Google Identity Platform / Firebase Authentication | Email, password, and social sign-in are free in base Firebase Authentication. Upgrading to Authentication with Identity Platform gives 50,000 MAU and 50 SAML/OIDC MAU at no cost and unlocks TOTP MFA; SMS is billed per message. | Rules and RLS still decide data access: [firebase-supabase.md](firebase-supabase.md). | | Auth0 (Okta) | Free plan: 25,000 MAU, passkeys, and unlimited social connections. **No MFA factors on Free**; Pro, Enterprise, and Adaptive MFA start at the paid plans. B2C Essentials is $35 per month for 500 MAU. | Passkeys on Free give a phishing-resistant single factor, which is not the same as MFA. | | Amazon Cognito | Lite and Essentials tiers: 10,000 MAU free for direct sign-in and 50 MAU free for SAML/OIDC federation. Essentials is $0.015 per MAU beyond that and includes passkeys; Lite does not. SMS goes through SNS and is billed separately. | Pairs with Application Load Balancer authentication ([cloud-identity-proxies.md](cloud-identity-proxies.md)). | | Clerk | Hobby plan: 50,000 monthly retained users (a user who returns at least 24 hours after signing up; not the same unit as MAU). **MFA is Pro and above** at $25 per month, or $20 billed annually. | "Free plan" does not mean MFA. | | WorkOS AuthKit | User management free for the first 1,000,000 MAU. Enterprise SSO and Directory Sync are separate products at $125 per connection per month for the first 15 connections each. | Good app login; the per-connection fees are for selling to enterprises. | | Supabase Auth | Free plan: 50,000 MAU with TOTP MFA included. Phone MFA is a paid add-on at $75 per month for the first project. | Enrolment is not enforcement: require the `aal2` level in your policies ([firebase-supabase.md](firebase-supabase.md)). | | Stytch, Descope, Kinde, Frontegg, Logto, Hanko, Zitadel, Ory, SuperTokens, FusionAuth | Same class. Logto, Hanko, Zitadel, Ory, and SuperTokens are open source with hosted tiers; FusionAuth is proprietary with a free community edition. Tiers not verified for this guide. | Check the current pricing page before choosing. | ## 4. Developer identity - **GitHub OAuth**: an OAuth app gives login for any GitHub user; restrict to members of your organisation or team in the app (or with oauth2-proxy, which has a GitHub provider with org and team restrictions). GitHub Actions OIDC is a different mechanism for workloads, covered in [machine-auth.md](machine-auth.md). - **AWS IAM Identity Center**: the free workforce directory for your team's own AWS console and CLI access. It is not a general OIDC provider for Application Load Balancer authentication; for app login on AWS pair the ALB with Cognito or an OIDC provider from section 3 ([cloud-identity-proxies.md](cloud-identity-proxies.md)). ## 5. Self-hosted identity providers Keycloak, authentik, Zitadel, Ory, and Authelia ([mfa.md](mfa.md)) give you OIDC and MFA without a vendor. They also give you a server to patch, back up, and keep highly available: a compromised or down identity provider takes every app with it. For a small project a hosted tier above is usually the smaller correct change. ## 6. MFA products - **Duo**: the Duo Free edition covers up to 10 users with MFA and the Duo Mobile app. Its Authentication Proxy speaks RADIUS and LDAP, which retrofits MFA onto VPNs and services with RADIUS support. - **Provider-native authenticators**: Microsoft Authenticator (Entra), Okta Verify (Okta), Google prompts (Google). Any RFC 6238 authenticator app works where a provider offers TOTP. - **Hardware keys and passkeys**: YubiKey and other FIDO2 keys, and platform passkeys, are the phishing-resistant factor. Most of the providers above document passkey or WebAuthn support; check the tier before relying on it, and require it for administrators ([mfa.md](mfa.md)). ## 7. Poor fits for a small project Zscaler Private Access, HashiCorp Boundary, Ping Identity, OneLogin, and Okta as a new purchase are enterprise products with enterprise pricing and sales-led onboarding. They are listed so an assistant recognises them when a customer already has them, not as recommendations. ## Verify - With only the password (or only a social login) an administrator cannot reach an admin function once MFA is required at the provider; test with a fresh session. - A valid account from outside your allowlist (a personal Gmail, a different tenant, a non-member GitHub user) is rejected after login, not admitted. - The provider's audit log shows the sign-in; your application log shows the identity it received. ## Sources (checked September 2026) - Microsoft Entra pricing (Free, P1, P2, External ID free MAU): https://www.microsoft.com/en-us/security/business/microsoft-entra-pricing - Microsoft Entra External ID billing model: https://learn.microsoft.com/en-us/entra/external-id/external-identities-pricing - Microsoft Entra security defaults: https://learn.microsoft.com/en-us/entra/fundamentals/security-defaults - Google Cloud Identity pricing: https://cloud.google.com/identity/pricing - Firebase pricing (Authentication and Identity Platform allowances): https://firebase.google.com/pricing - Okta pricing: https://www.okta.com/pricing/ - Auth0 pricing: https://auth0.com/pricing - Amazon Cognito pricing: https://aws.amazon.com/cognito/pricing/ - Clerk pricing: https://clerk.com/pricing - WorkOS pricing: https://workos.com/pricing - Supabase pricing: https://supabase.com/pricing - Duo editions and pricing: https://duo.com/editions-and-pricing - GitHub OAuth apps: https://docs.github.com/en/apps/oauth-apps - AWS IAM Identity Center: https://docs.aws.amazon.com/singlesignon/latest/userguide/what-is.html ; its OIDC service (AWS CLI and native clients): https://docs.aws.amazon.com/singlesignon/latest/OIDCAPIReference/Welcome.html - Application Load Balancer user authentication (OIDC-compliant IdP or Cognito user pool): https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html ====================================================================== ==> oidc-integration.md ====================================================================== # OIDC login: wiring Google, Microsoft Entra, GitHub, and Okta into your app Adding "Sign in with Google" takes an afternoon; the recurring defects are in what happens after the redirect comes back: an unvalidated ID token, an account matched by email, or an app that admits every Google or Microsoft account in existence because nobody checked whose it was. This guide gives the one flow every recipe shares, the exact claim to check per provider, the registration steps, and library pointers. Choosing a provider is covered in [identity-providers.md](identity-providers.md); login placed in front of an app without code changes is covered in [cloud-identity-proxies.md](cloud-identity-proxies.md). ## 1. The flow every recipe uses Authorization code flow with PKCE from a server-side (confidential) client. RFC 9700 requires PKCE for public clients and recommends it for all others, web applications included; it requires exact string matching of redirect URIs at the authorization server; and it says clients should not use the implicit grant (`response_type=token`). 1. Register the exact callback URL at the provider (scheme, host, path, trailing slash). Prefix or wildcard matching is what makes redirect-based code theft work. 2. Send a random `state` bound to the browser session and a random `nonce` on every authorization request, plus `code_challenge` with `code_challenge_method=S256`. Scopes: `openid email profile`. 3. Exchange the code at the token endpoint from the server, with the client secret. Never from the browser. 4. Validate the ID token before trusting any claim (OpenID Connect Core section 3.1.3.7): the signature against the key set at the provider's `jwks_uri`, using an algorithm you pinned (Core says RS256 unless you registered another; the discovery document lists the provider's `id_token_signing_alg_values_supported`) rather than whatever the token header names; `iss` exactly equals the issuer you configured; `aud` contains your client ID; `exp` is in the future; `nonce` equals the one you sent. 5. Start a server-side session and give the browser only a session cookie marked `Secure`, `HttpOnly`, and `SameSite` per [authentication.md](authentication.md). Do not put ID or access tokens in `localStorage` or a script-readable cookie; nothing in the browser needs them. 6. Link the identity to a local account by the pair (issuer, `sub`). OpenID Connect Core section 5.7 says `email`, `phone_number`, and `preferred_username` are not guaranteed unique and may change; Google and Microsoft document the same for their `email` claims. Matching by email alone lets a re-used or unverified address take over an account. 7. Logout: destroy the server-side session and expire the cookie; where the discovery document lists an `end_session_endpoint`, also redirect there with `id_token_hint` and a registered `post_logout_redirect_uri` (Entra: `/oauth2/v2.0/logout`). The discovery document at `/.well-known/openid-configuration` supplies the endpoints and `jwks_uri`; its `issuer` value must be identical to the prefix you fetched it from. Every library in section 4 reads it for you. ## 2. Login is not authorisation The provider proves who the person is. Whether they may use your app is your check, run after token validation and before the session exists, against an allowlist. The claim differs per provider: - **Google**: the `hd` claim in the ID token must equal your Workspace domain (`example.com`). The `hd` request parameter only optimises the account picker; Google's documentation says not to rely on it for access control and to validate the returned `hd` claim. Treat a missing `hd` claim as a rejection. - **Microsoft Entra**: the `tid` claim must be your tenant ID and `iss` must be `https://login.microsoftonline.com//v2.0`. Register the app as **Single tenant only** when only your organisation signs in. The `organizations` authority accepts any Entra tenant and `common` also accepts personal accounts; the issuer then varies per tenant, so an app on those authorities that does not check `tid` (or the GUID in `iss`) admits every Microsoft account. Microsoft documents `email` and `preferred_username` as mutable and unfit for authorisation; key the local account on `oid` or `sub` plus `tid`. - **GitHub**: GitHub OAuth apps speak OAuth 2.0, not OpenID Connect; there is no discovery document and no ID token. After the token exchange call `GET https://api.github.com/user` for the identity, then check `GET /user/memberships/orgs/` (200 with `"state": "active"` means a member; 404 means not affiliated) or a team with `GET /orgs//teams//memberships/` (200 with `"state": "active"`; the REST reference documents `pending` for an unaccepted invitation, and 404 means no membership). Both need the `read:org` scope. Key the local account on the numeric `id` from `/user`, not the `login`. - **Okta**: add a `groups` claim to the ID token (org authorization server: **Applications > Applications >** your app **> Sign On**, edit the OpenID Connect ID Token section, filter **Matches regex** `.*`; the client must also request the `groups` scope) and require membership of a named group. The claim holds at most 100 groups and the request fails beyond that; use a narrower filter in large orgs. The default for an identity that passes none of these is to reject and log it, never to create a pending account an admin later forgets to review. ## 3. Register the app at each provider The client secret is a secret: environment variable or secret manager, never a repository or an image ([secrets.md](secrets.md)). Substitute the callback path your library expects for `https://app.example.com/auth/callback`. - **Google**: Google Cloud console **Clients** page (`https://console.developers.google.com/auth/clients`); create an OAuth client and add the redirect URI. The match is exact, including scheme, case, and trailing slash. Discovery: `https://accounts.google.com/.well-known/openid-configuration`; `iss` is `https://accounts.google.com` or `accounts.google.com`. - **Microsoft Entra**: Microsoft Entra admin center, **Entra ID > App registrations > New registration**; under **Supported account types** choose **Single tenant only** unless you are building for other organisations. Then **Authentication > Add a platform > Web** and add the redirect URI. Record the Application (client) ID and create a client secret. Discovery: `https://login.microsoftonline.com//v2.0/.well-known/openid-configuration` with `` your directory (tenant) ID; `common` or `organizations` only together with the `tid` check above. - **GitHub**: profile picture **> Settings > Developer settings > OAuth apps > New OAuth App**; set the Authorization callback URL. Authorize at `https://github.com/login/oauth/authorize` with `client_id`, `redirect_uri`, `scope=read:user read:org`, `state`, and PKCE (`code_challenge` with `code_challenge_method=S256`; GitHub does not accept `plain`); exchange at `https://github.com/login/oauth/access_token` with `client_id`, `client_secret`, `code`, the `code_verifier` that produced the challenge, and the same `redirect_uri`. Always send `redirect_uri`; when it is absent GitHub uses the first registered callback. Apps that had a single callback URL before August 3, 2026 keep wildcard matching for it, which accepts any subdirectory path on the same host; disable wildcard matching in the app settings so the callback must match exactly. - **Okta**: Admin Console, **Applications and Resources > Applications > Create App Integration**, sign-in method **OIDC - OpenID Connect**, type **Web Application**; set the sign-in and sign-out redirect URIs and the assignment (Okta's guide allows everyone in the org; narrow it to a group). Client ID and secret are on the **General** tab under Client Credentials. Discovery: `https://.okta.com/.well-known/openid-configuration` for the org authorization server, `https://.okta.com/oauth2//.well-known/openid-configuration` for a custom one; `iss` equals that prefix. ## 4. Libraries Use a maintained library; do not hand-parse JWTs. The snippets are from each library's documentation; the section 2 check is yours to add. - **Node, [openid-client](https://github.com/panva/openid-client)** (`npm install openid-client`): ```javascript let config = await client.discovery(server, clientId, clientSecret) let code_verifier = client.randomPKCECodeVerifier() let code_challenge = await client.calculatePKCECodeChallenge(code_verifier) let state = client.randomState() let redirectTo = client.buildAuthorizationUrl(config, { redirect_uri, scope, code_challenge, code_challenge_method: 'S256', state }) // callback: let tokens = await client.authorizationCodeGrant(config, currentUrl, { pkceCodeVerifier: code_verifier, expectedState: state }) ``` - **Node, [Auth.js](https://authjs.dev/)** (`npm install next-auth@beta` for Next.js; SvelteKit and Express integrations exist): providers `next-auth/providers/google`, `github`, `okta`, and `microsoft-entra-id`, configured through `AUTH__ID`, `AUTH__SECRET`, and for Okta and Entra `AUTH__ISSUER`; callbacks land on `/api/auth/callback/`. Sessions are an encrypted JWT, or a database session ID, in an `HttpOnly` cookie. With the JWT strategy, sign-out destroys the cookie but the token itself stays valid until `exp` unless your server keeps a blocklist (Auth.js documents this limitation); use database sessions where immediate invalidation matters. Set `AUTH_MICROSOFT_ENTRA_ID_ISSUER` to your tenant's `/v2.0` issuer: the documented default is `common`. Put the section 2 check in the `signIn` callback. - **Python, [Authlib](https://docs.authlib.org/)** (`pip install Authlib`; Flask, Django, Starlette, FastAPI): ```python oauth.register('google', client_id='YOUR_CLIENT_ID', client_secret='YOUR_CLIENT_SECRET', server_metadata_url='https://accounts.google.com/.well-known/openid-configuration', client_kwargs={'scope': 'openid profile email'}) # login: return oauth.google.authorize_redirect(redirect_uri) # callback: token = oauth.google.authorize_access_token(); claims = token['userinfo'] ``` - **Go, [go-oidc](https://pkg.go.dev/github.com/coreos/go-oidc/v3/oidc)** (`github.com/coreos/go-oidc/v3/oidc`): ```go provider, err := oidc.NewProvider(ctx, "https://accounts.google.com") verifier := provider.Verifier(&oidc.Config{ClientID: clientID}) idToken, err := verifier.Verify(ctx, rawIDToken) // signature, issuer, audience, expiry ``` The package documents that it does not check the nonce value: compare `idToken.Nonce` to the one you stored. Leave `SkipIssuerCheck` off. ## 5. Where MFA comes from Your app does not run the second factor; the provider does, under its policy: Conditional Access in Entra (**Entra ID > Conditional Access > Policies**, grant **Require authentication strength**; a P1 feature per [identity-providers.md](identity-providers.md)), 2-Step Verification in the Google Admin console (**Security > Authentication > 2-step verification**, Enforcement **On**), and authentication policies plus the global session policy in Okta. Ordering and options in [mfa.md](mfa.md). An app can additionally refuse a session whose ID token shows no second factor, where the provider documents the claim. Okta's `amr` array carries values such as `pwd`, `mfa`, `otp`, and `hwk`. For Entra, check the ID token claims reference and the optional claims reference for the `amr` claim and its `mfa` value before relying on it. For Google, enforce 2SV in Workspace; no ID token MFA claim was verified for this guide. GitHub OAuth has no ID token, so MFA is whatever the organisation requires of its members. ## Verify ```bash curl -s https://accounts.google.com/.well-known/openid-configuration | jq -r '.issuer, .jwks_uri' curl -sI https://app.example.com/admin | head -1 # 302 to login or 401, never 200 ``` Negative tests matter more than the happy path: - Sign in with a valid account outside the allowlist (a personal Gmail, another Entra tenant, a GitHub user outside the org, an Okta user outside the group): the provider authenticates, your app refuses and logs the identity. - Edit `redirect_uri` in the authorization URL (add a path segment or change the host): the provider shows an error and never redirects. - Change `state` on the callback URL: your app rejects the callback. - Replay a captured ID token after `exp`, or one issued to a different client ID at the same provider: your callback rejects it. - Log out, then reload a protected page: it redirects to login. With database sessions the old session cookie no longer works; with JWT sessions it works until `exp`, so use database sessions or a revocation check where immediate invalidation matters. ## Sources (checked September 2026) - RFC 9700, OAuth 2.0 Security Best Current Practice: https://www.rfc-editor.org/rfc/rfc9700 - OpenID Connect Core 1.0 (ID token validation 3.1.3.7, claim stability 5.7): https://openid.net/specs/openid-connect-core-1_0.html ; Discovery 1.0: https://openid.net/specs/openid-connect-discovery-1_0.html ; RP-Initiated Logout 1.0: https://openid.net/specs/openid-connect-rpinitiated-1_0.html - Google OpenID Connect (discovery URL, `hd`, `sub` versus `email`, token validation): https://developers.google.com/identity/openid-connect/openid-connect - Google Workspace: deploy 2-Step Verification: https://knowledge.workspace.google.com/admin/security/deploy-2-step-verification - Microsoft identity platform: register an application: https://learn.microsoft.com/en-us/entra/identity-platform/quickstart-register-app ; OpenID Connect (discovery, `{tenant}` values, redirect URI, sign-out): https://learn.microsoft.com/en-us/entra/identity-platform/v2-protocols-oidc - Microsoft identity platform: ID token claims reference (`tid`, `iss`, `oid`, `sub`, `email`): https://learn.microsoft.com/en-us/entra/identity-platform/id-token-claims-reference ; optional claims reference (`amr`, `mfa` value): https://learn.microsoft.com/en-us/entra/identity-platform/optional-claims-reference - Microsoft Entra Conditional Access: require MFA for all users: https://learn.microsoft.com/en-us/entra/identity/conditional-access/policy-all-users-mfa-strength - GitHub OAuth apps: authorizing: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps ; creating: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/creating-an-oauth-app ; scopes: https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/scopes-for-oauth-apps - GitHub REST: organization members: https://docs.github.com/en/rest/orgs/members ; team members: https://docs.github.com/en/rest/teams/members - Okta: OAuth 2.0 and OpenID Connect overview: https://developer.okta.com/docs/concepts/oauth-openid/ ; OIDC API reference (discovery, issuer, `amr`, `groups`): https://developer.okta.com/docs/reference/api/oidc/ - Okta: add a groups claim: https://developer.okta.com/docs/guides/customize-tokens-groups-claim/main/ ; sign users in to your web application: https://developer.okta.com/docs/guides/sign-into-web-app-redirect/-/main/ ; policies concept: https://developer.okta.com/docs/concepts/policies/ - openid-client: https://github.com/panva/openid-client - Auth.js (installation, providers): https://authjs.dev/ ; session strategies (a JWT cannot be expired early without a blocklist): https://authjs.dev/concepts/session-strategies - Authlib Flask client: https://docs.authlib.org/en/stable/oauth2/client/web/flask.html - go-oidc: https://pkg.go.dev/github.com/coreos/go-oidc/v3/oidc ====================================================================== ==> cloud-identity-proxies.md ====================================================================== # Identity-aware proxies: login in front of the app with no code change An identity-aware proxy puts a login page in front of an application without changing the application: the cloud's load balancer, platform edge, or tunnel authenticates the user against an identity provider and forwards the request with the user's identity in headers. [cloudflare.md](cloudflare.md) documents the pattern for Cloudflare Access; this guide covers the equivalents in AWS, Google Cloud, Azure, ngrok, and Vercel. The pattern fails in two ways: the origin stays reachable around the proxy, or the app trusts an identity header anyone can type. Both are addressed below. ## 1. Rules common to every proxy 1. **The origin accepts traffic only from the proxy.** Bind to loopback behind a tunnel, reference the load balancer's security group, or restrict ingress to the load balancer. If the app also answers directly, the login page is decorative ([cloud-firewalls.md](cloud-firewalls.md)). 2. **The app never trusts a plain identity header.** Where the proxy provides a signed assertion (AWS, Google Cloud, Cloudflare), verify its signature, issuer, audience, and expiry before using any claim. Where the platform documents that clients cannot set the identity headers (Azure), that guarantee holds only for requests that arrived through the platform. 3. **MFA comes from the identity provider behind the proxy.** None of these proxies adds a second factor of its own; enforce it at the IdP and the proxy inherits it ([mfa.md](mfa.md), [identity-providers.md](identity-providers.md)). 4. Authorization is still yours: the proxy proves who the user is, and the app or the proxy policy decides what they may do ([authentication.md](authentication.md)). 5. **Fail closed.** The auth layer must deny when it cannot reach a decision: missing auth configuration, an unreachable authorization service, and an unmatched route must never fall through to the app unauthenticated. Test this with a fresh, unauthenticated request against a protected route while the authorization service is unreachable, and require denial, not pass-through; an already-authenticated session is a separate question, since a proxy is not guaranteed to recheck the identity provider on every request. ## 2. AWS Application Load Balancer A listener rule with an `authenticate-oidc` (any OIDC provider) or `authenticate-cognito` (Cognito user pool, including social and SAML federation) action, followed by a `forward` action. Both action types work only on HTTPS listeners. The `OnUnauthenticatedRequest` field takes `authenticate` (the default: redirect to the IdP), `allow` (forward without claims, for pages with a public view), or `deny` (HTTP 401). `SessionTimeout` defaults to 7 days and can be set as short as 1 second. The IdP must allow `https:///oauth2/idpresponse` as a redirect URL. ```json { "Type": "authenticate-oidc", "AuthenticateOidcConfig": { "Issuer": "https://idp.example.com", "AuthorizationEndpoint": "...", "TokenEndpoint": "...", "UserInfoEndpoint": "...", "ClientId": "...", "ClientSecret": "REPLACE_WITH_LONG_RANDOM_VALUE", "SessionTimeout": 3600, "OnUnauthenticatedRequest": "deny" }, "Order": 1 } ``` The load balancer forwards the user claims to targets in `x-amzn-oidc-data`, a JWT signed with ES256. The app must verify that signature (public key from `https://public-keys.auth.elb..amazonaws.com/`, key ID from the JWT header) and confirm that the `signer` field in the JWT header is your load balancer's ARN before using any claim; AWS publishes the `aws-jwt-verify` library for this. `x-amzn-oidc-accesstoken` and `x-amzn-oidc-identity` are unsigned legacy headers and cannot be verified, so never use them for identity. Restrict the targets' security group to accept traffic only from the load balancer's security group, and use an HTTPS target group if the claims must be encrypted on the last hop. ## 3. Google Cloud Identity-Aware Proxy IAP protects App Engine, Cloud Run, Compute Engine, GKE, and on-premises applications (plus Cloud Storage buckets). A user reaches the app only if they hold the **IAP-secured Web App User** IAM role on the resource; identities can come from Google Accounts, from an external IdP through Workforce Identity Federation, or from customers through Identity Platform. IAP adds `x-goog-iap-jwt-assertion`, an ES256 JWT. Verify the signature against the keys at `https://www.gstatic.com/iap/verify/public_key-jwk`, check `iss` is `https://cloud.google.com/iap`, and check `aud` matches your resource (`/projects/PROJECT_NUMBER/global/backendServices/SERVICE_ID` for a backend service, `/projects/PROJECT_NUMBER/locations/REGION/services/SERVICE_NAME` for Cloud Run, `/projects/PROJECT_NUMBER/apps/PROJECT_ID` for App Engine). The unsigned `x-goog-authenticated-user-email` and `x-goog-authenticated-user-id` headers can be forged by anyone who bypasses IAP; Google's docs describe the JWT as the secure alternative. Bypass is the main risk. Google's docs say to verify whether backend resources can be reached directly when IAP is enabled on a load balancer, and that IAP on a load balancer secures only traffic through that load balancer, not traffic that reaches a Cloud Run service through its `run.app` URL. Either enable IAP directly on the Cloud Run service (the documented recommendation, no load balancer needed), or disable the default URL or restrict ingress so only the load balancer reaches it. For Compute Engine and GKE, firewall rules must block traffic that does not come through the load balancer. ## 4. Azure App Service and Azure Functions Built-in authentication ("Easy Auth") is a platform module in front of the app; per the docs no SDK, language, or application code change is required. Providers: Microsoft Entra, Facebook, Google, X, GitHub, Apple (preview at the time of writing), and any OpenID Connect provider. Under **Settings > Authentication**, choose **Require authentication** to reject unauthenticated traffic (as an HTTP 302 redirect to the provider, recommended for websites; or 401, recommended for APIs; or 403 or 404), or **Allow unauthenticated requests** to let the app decide. Enabling the feature redirects all requests to HTTPS regardless of the app's enforce-HTTPS setting (`requireHttps` in the V2 configuration can turn this off; do not). Requiring authentication applies to every path; exceptions need a configuration file with excluded paths. The app receives the identity in `X-MS-CLIENT-PRINCIPAL` (Base64 JSON of the claims), `X-MS-CLIENT-PRINCIPAL-ID`, `X-MS-CLIENT-PRINCIPAL-NAME`, and `X-MS-CLIENT-PRINCIPAL-IDP`, with `/.auth/me` available when the token store is on. The docs state that external requests are not allowed to set these headers, so they are present only if App Service set them. With the Entra provider, any user in the tenant can obtain a token by default; restrict the app registration to assigned users if that is not intended. **Azure Container Apps** uses the same authentication system, run as a sidecar container on each replica, with Microsoft Entra ID, Facebook, GitHub, Google, X, and custom OpenID Connect providers, and the same **Require authentication** / **Allow unauthenticated access** choice and `X-MS-CLIENT-PRINCIPAL-*` headers that external requests cannot set. The docs require HTTPS only: `allowInsecure` must be disabled on the ingress. **Azure Static Web Apps** authenticates with GitHub and Microsoft Entra ID out of the box on all plans (a registered custom provider replaces the preconfigured ones; X is no longer preconfigured). Sign-in is at `/.auth/login/github` or `/.auth/login/aad`, signed-in users hold the `anonymous` and `authenticated` roles, and route rules in `staticwebapp.config.json` restrict paths by role. The preconfigured Entra provider accepts any Microsoft account; to limit sign-in to one tenant, configure a custom Entra provider. ## 5. Cloudflare Access Set up the tunnel and Access policy per [cloudflare.md](cloudflare.md). Then have the app validate the `Cf-Access-Jwt-Assertion` header (preferred over the `CF_Authorization` cookie, which is not guaranteed to be passed) against the public keys at `https://.cloudflareaccess.com/cdn-cgi/access/certs`, checking that `aud` equals the application's AUD tag and `iss` is your team domain. Access rotates the signing key every 6 weeks with the previous key valid for 7 days, so fetch keys from the endpoint rather than hard-coding them. ## 6. ngrok ngrok's traffic policy `oauth` action (providers include `google`, `github`, and `microsoft`) and `openid-connect` action (`issuer_url`, `client_id`, `client_secret`, `scopes`) redirect unauthenticated visitors to the provider, so a local tool becomes public only behind a login. Restrict who gets through with an expression on the identity the action exposes: ```yaml on_http_request: - actions: - type: oauth config: provider: google - expressions: - "!actions.ngrok.oauth.identity.email.endsWith('@example.com')" actions: - type: deny config: status_code: 403 ``` Use `actions.ngrok.oauth.identity.email in ['alice@example.com']` for an explicit list. Leaving `client_id` and `client_secret` empty uses ngrok's managed OAuth application for the supported providers. Run with `ngrok http 3000 --traffic-policy-file policy.yml`; the older `--oauth`, `--oauth-allow-domain`, and `--oauth-allow-email` agent flags are marked deprecated in favour of traffic policy. ## 7. Vercel Deployment Protection Vercel's protection guards a deployment from the public; it is not your application's user login. **Vercel Authentication** (all plans) admits logged-in team or project members with at least a viewer role, users granted access on request, holders of a shareable link, and automation with the bypass header. **Standard Protection** covers preview deployments and generated deployment URLs but not production domains; the **All Deployments** scope closes that gap, and Vercel's September 9, 2026 change made pairing it with Vercel Authentication free on every plan, including Hobby, rather than Pro and Enterprise only (per Vercel's Deployment Protection changelog, at the time of writing; the configuration reference page itself still listed All Deployments as Pro and Enterprise only when checked, so confirm current availability in your own dashboard). Until All Deployments protection is configured, the production domain stays public on every plan. **Password Protection** is an Enterprise feature or a paid Pro add-on (Advanced Deployment Protection, USD 150 per month at the time of writing); Trusted IPs and Passport (your own IdP) are Enterprise only. ## Verify ```bash curl -sI http://203.0.113.10:3000/ # origin direct: timeout or connection refused curl -sI https://app.example.com/ # via proxy, no session: 302 to the IdP, or 401/403 curl -s -H "x-amzn-oidc-identity: admin" \ -H "X-MS-CLIENT-PRINCIPAL-NAME: admin" \ -H "X-Goog-Authenticated-User-Email: admin@example.com" \ https://app.example.com/whoami # still the login redirect; never "admin" ``` After logging in, confirm that the app's own identity check reads the signed assertion (`x-amzn-oidc-data`, `x-goog-iap-jwt-assertion`, `Cf-Access-Jwt-Assertion`) and rejects a request carrying a tampered one. - Stop the authorization service, or break its configuration, and send a fresh, unauthenticated request to a protected route: it must be denied, never passed through to the app. This checks that an undecidable auth state fails closed; it does not test whether an already-established session survives the outage, since a proxy is not required to recheck the identity provider on every request. ## Common mistakes - Enabling IAP or ALB authentication while the Cloud Run `run.app` URL, the instance's public IP, or a second listener still serves the app directly. - Reading `x-amzn-oidc-identity`, `X-Goog-Authenticated-User-Email`, or a similar unsigned header as the user identity. - Treating Vercel Authentication as end-user login, or leaving production on Standard Protection and assuming it is covered. - Using the ngrok `oauth` action without a `deny` rule on email or domain, which lets anyone with a Google account in. ## Sources (checked September 2026) - AWS, Authenticate users using an Application Load Balancer: https://docs.aws.amazon.com/elasticloadbalancing/latest/application/listener-authenticate-users.html - Google Cloud IAP overview: https://docs.cloud.google.com/iap/docs/concepts-overview - Google Cloud IAP, securing your app with signed headers: https://docs.cloud.google.com/iap/docs/signed-headers-howto - Google Cloud IAP, enabling IAP for Cloud Run: https://docs.cloud.google.com/iap/docs/enabling-cloud-run - Azure App Service authentication and authorization: https://learn.microsoft.com/en-us/azure/app-service/overview-authentication-authorization - Azure App Service, work with user identities (headers): https://learn.microsoft.com/en-us/azure/app-service/configure-authentication-user-identities - Azure Container Apps authentication: https://learn.microsoft.com/en-us/azure/container-apps/authentication - Azure Static Web Apps authentication and authorization: https://learn.microsoft.com/en-us/azure/static-web-apps/authentication-authorization - Cloudflare Access, validate JWTs: https://developers.cloudflare.com/cloudflare-one/access-controls/applications/http-apps/authorization-cookie/validating-json/ - ngrok traffic policy OAuth action: https://ngrok.com/docs/traffic-policy/actions/oauth/ - ngrok traffic policy OpenID Connect action: https://ngrok.com/docs/traffic-policy/actions/oidc/ - ngrok agent CLI (`ngrok http` flags): https://ngrok.com/docs/agent/cli/ - Vercel Deployment Protection: https://vercel.com/docs/deployment-protection - Vercel Authentication: https://vercel.com/docs/deployment-protection/methods-to-protect-deployments/vercel-authentication ====================================================================== ==> machine-auth.md ====================================================================== # Machine identity: API keys, client credentials, mutual TLS, and workload identity Machines cannot do MFA, so their credentials are long-lived by default, and long-lived credentials leak through repositories, container images, and logs. The fix is scoped, short-lived, and where possible credential-free access: a CI job or workload that holds no key cannot leak one. [authentication.md](authentication.md) sets the baseline and [secrets.md](secrets.md) covers handling and leak response; this guide covers the credential types themselves, from the weakest to the one that removes the secret entirely. ## 1. API keys and bearer tokens The simplest machine credential and the one that leaks most. When your service issues or accepts them: - One key per client and per environment, generated randomly (commands in [secrets.md](secrets.md)), with the least privilege that client's task needs. A shared key cannot be revoked for one client without breaking the rest. - Give every key an expiry and rotate on a schedule, with a short overlap during which both keys work, so rotation is routine rather than an outage. - Send keys only in a header (`Authorization: Bearer ...`) over TLS, never in a URL. RFC 6750 makes TLS mandatory for bearer tokens and says they "SHOULD NOT be passed in page URLs", because URLs land in browser history, proxy logs, and server logs. - Compare the presented key in constant time: `hmac.compare_digest()` in Python, `crypto.timingSafeEqual()` in Node.js (both arguments must have the same byte length). A plain `==` stops at the first differing byte and leaks timing. - Where the service only needs to verify the key, store a hash of it and compare against the hash of the presented key. Show the plaintext once at creation; a database dump then yields no usable keys. ## 2. OAuth 2.0 client credentials For service-to-service calls to an identity provider or an API that supports it, use the client credentials grant (RFC 6749 section 4.4): the client authenticates to the token endpoint with `grant_type=client_credentials` and receives a short-lived access token; no refresh token is issued, and the grant is for confidential clients only. Request the narrowest `scope` (or audience, where the provider uses one) the call needs, so a stolen token is bounded in time and reach, and validate the audience on the receiving side ([oidc-integration.md](oidc-integration.md)). The client secret is still a long-lived credential: store it per section 5, or replace it with a federated credential per section 4 where the provider allows. Hosted providers may bill this flow: Microsoft Entra External ID charges machine-to-machine authentication per transaction as an add-on, so a token refresh every hour is roughly 720 billable transactions a month (as of September 2026; tiers in [identity-providers.md](identity-providers.md)). ## 3. Mutual TLS A client certificate from your own internal CA ([self-signed.md](self-signed.md)) is a possession factor for a machine: the private key never crosses the wire, and a replaced client key cuts off one client, not all of them. Replacing a key does not by itself reject the old certificate: revoke it (a CRL or OCSP the server checks), remove it from an explicit allowlist, or let it expire, and test that the old credential is refused (RFC 5280 covers revocation). It is not human MFA, and [mfa.md](mfa.md) still applies to every human path that reaches the host. The service guides already carry the server-side directives: `ssl_verify_client on` in [nginx.md](nginx.md), `SSLVerifyClient require` in [apache.md](apache.md), `clientcert=verify-full` in [postgresql.md](postgresql.md), `REQUIRE X509` in [mysql.md](mysql.md), `tls-auth-clients yes` in [redis.md](redis.md), `ssl_options.fail_if_no_peer_cert = true` in [rabbitmq.md](rabbitmq.md), and `require_certificate true` in [mosquitto.md](mosquitto.md). Issue one certificate per client, keep the CA key off the servers it signs for, and set short lifetimes so a lost key expires rather than lingers. ## 4. Workload identity federation: no long-lived cloud keys in CI A GitHub Actions job can request a short-lived OIDC token (`permissions: id-token: write`) issued by `https://token.actions.githubusercontent.com` with a `sub` claim such as `repo:octo-org/octo-repo:ref:refs/heads/main` or `repo:octo-org/octo-repo:environment:prod`. The cloud provider trusts that issuer and exchanges the token for temporary credentials, so the repository stores no cloud key at all. The key point is the trust condition: every GitHub repository uses the same issuer, so a federation trust that admits any repository is a leaked key with extra steps. Condition on the exact repository and on the branch or environment. **AWS.** The `aws-actions/configure-aws-credentials` step takes `role-to-assume` and `aws-region` and sends `sts.amazonaws.com` as the audience by default. The role's trust policy does the restricting: ```json { "Version": "2012-10-17", "Statement": [{ "Effect": "Allow", "Principal": { "Federated": "arn:aws:iam::123456789012:oidc-provider/token.actions.githubusercontent.com" }, "Action": "sts:AssumeRoleWithWebIdentity", "Condition": { "StringEquals": { "token.actions.githubusercontent.com:aud": "sts.amazonaws.com", "token.actions.githubusercontent.com:sub": "repo:example-org/example-repo:ref:refs/heads/main" } } }] } ``` IAM refuses a trust policy whose `sub` condition is absent or only a wildcard, and AWS warns that a condition wider than your organisation lets "GitHub Actions from organizations or repositories outside of your control" assume the role. **Google Cloud.** Create a Workload Identity Pool provider with `gcloud iam workload-identity-pools providers create-oidc` using `--issuer-uri="https://token.actions.githubusercontent.com"`, an `--attribute-mapping` such as `google.subject=assertion.sub,attribute.repository=assertion.repository`, and an `--attribute-condition` such as `assertion.repository_owner == 'example-org'`. Then grant `roles/iam.workloadIdentityUser` on the service account to `principalSet://iam.googleapis.com//attribute.repository/example-org/example-repo`, which names the exact repository. Google recommends conditions on the numeric `repository_id` and `repository_owner_id` claims over names, since a name can be re-registered by someone else. The `google-github-actions/auth` step takes `workload_identity_provider` and `service_account`. **Azure.** Add a federated credential on the app registration or user-assigned managed identity with issuer `https://token.actions.githubusercontent.com`, subject `repo:example-org/example-repo:environment:production` (or `repo:example-org/example-repo:ref:refs/heads/main`), and audience `api://AzureADTokenExchange`, for example `az ad app federated-credential create --id --parameters credential.json`. Wildcards are not supported and the subject must match exactly; a wrong subject is accepted at creation and fails only at exchange time, with no error message. The `azure/login` step then needs only `client-id`, `tenant-id`, and `subscription-id`; there is no client secret to store. Two cautions. GitHub documents an immutable default `sub` format that includes owner and repository IDs for repositories created after July 15, 2026 (as of September 2026), so read the claim your token actually carries before writing the condition. And pin every action to a release tag or commit SHA. Inside a cluster, [SPIFFE/SPIRE](https://spiffe.io/) is the equivalent: attested, short-lived identities issued to workloads without a stored secret. ## 5. Store the machine credentials that must exist API keys, client secrets, and client-certificate keys that cannot be federated away go in a secret manager or the platform's own store ([secrets.md](secrets.md)): AWS Secrets Manager (https://aws.amazon.com/secrets-manager/), Google Cloud Secret Manager (https://docs.cloud.google.com/secret-manager/docs/overview), Azure Key Vault (https://azure.microsoft.com/en-us/products/key-vault), HashiCorp Vault (https://developer.hashicorp.com/vault) or OpenBao, its open-source fork under the Linux Foundation (https://openbao.org/), Infisical (https://infisical.com/), Doppler (https://www.doppler.com/), 1Password Secrets Automation (https://www.1password.dev/secrets-automation/), and Bitwarden Secrets Manager (https://bitwarden.com/products/secrets-manager/). The workload should authenticate to the store with its platform identity (an instance role, a managed identity, or the federation above) so the store does not become one more long-lived key. ## Verify - A key issued to another client or environment is rejected: `curl -sS -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer " https://api.example.com/v1/status` against production returns `401` or `403`, never `200`. - A revoked or expired key or token is rejected the same way, and the rejection appears in the service log with the client identity. - The CI job holds no long-lived cloud key: the repository's Actions secrets contain no `AWS_SECRET_ACCESS_KEY`, service-account JSON, or Azure client secret, and the workflow declares `permissions: id-token: write`. - The federation trust condition names the exact repository: the AWS `sub` condition, the Google `attribute.repository` binding, and the Azure `subject` each contain `example-org/example-repo`, and none is `repo:example-org/*` or a bare wildcard. - `gitleaks git .` and `docker history ` show no key ([secrets.md](secrets.md), [docker.md](docker.md)). ## Sources (checked September 2026) - OAuth 2.0 client credentials grant (RFC 6749 section 4.4): https://www.rfc-editor.org/rfc/rfc6749#section-4.4 - OAuth 2.0 bearer token usage, TLS and URL rules (RFC 6750 sections 5.2 and 5.3): https://www.rfc-editor.org/rfc/rfc6750 - Python `hmac.compare_digest`: https://docs.python.org/3/library/hmac.html ; Node.js `crypto.timingSafeEqual`: https://nodejs.org/api/crypto.html - Microsoft Entra External ID billing model (M2M add-on): https://learn.microsoft.com/en-us/entra/external-id/external-identities-pricing - GitHub: about security hardening with OpenID Connect (claims, subject formats): https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/about-security-hardening-with-openid-connect - GitHub: configuring OpenID Connect in AWS: https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-amazon-web-services ; in Google Cloud: https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-google-cloud-platform ; in Azure: https://docs.github.com/en/actions/security-for-github-actions/security-hardening-your-deployments/configuring-openid-connect-in-azure - AWS IAM: configuring a role for the GitHub OIDC identity provider (trust policy, `sub` restriction): https://docs.aws.amazon.com/IAM/latest/UserGuide/id_roles_create_for-idp_oidc.html - aws-actions/configure-aws-credentials: https://github.com/aws-actions/configure-aws-credentials - Google Cloud: Workload Identity Federation with deployment pipelines (GitHub Actions attribute mapping and conditions): https://docs.cloud.google.com/iam/docs/workload-identity-federation-with-deployment-pipelines ; attribute conditions: https://docs.cloud.google.com/iam/docs/workload-identity-federation - google-github-actions/auth: https://github.com/google-github-actions/auth - Microsoft Entra: workload identity federation: https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation ; creating the trust on an app (subject formats, audience, exact match): https://learn.microsoft.com/en-us/entra/workload-id/workload-identity-federation-create-trust - Azure: authenticate from GitHub Actions by OpenID Connect (`azure/login`): https://learn.microsoft.com/en-us/azure/developer/github/connect-from-azure-openid-connect - SPIFFE and SPIRE: https://spiffe.io/ and https://spiffe.io/docs/latest/spire-about/ - RFC 5280 (certificate revocation): https://www.rfc-editor.org/rfc/rfc5280 - Secret manager vendor pages: linked inline in section 5. ====================================================================== ==> fronting-auth.md ====================================================================== # Fronting auth: putting login and MFA in front of an app that has none (oauth2-proxy, Authelia, Pomerium) Many self-hosted apps (internal tools, dashboards, webhook receivers) ship with no login at all. The fix is the same shape every time: the app binds to loopback, a proxy in front does authentication and MFA, and it passes the app a verified identity, which the app must not accept from anywhere else. This guide is what [mfa.md](mfa.md), [nginx.md](nginx.md), [traefik.md](traefik.md), and [caddy.md](caddy.md) point at. ## 1. The pattern 1. **Bind the app to loopback**, `127.0.0.1`, never `0.0.0.0` or `::`. If the app also answers directly, the login page in front of it is decorative. 2. **A proxy authenticates first**: oauth2-proxy, Authelia, or Pomerium checks the session before the request reaches the app; an unauthenticated request never gets there. 3. **The proxy passes identity in a header** (`X-Auth-Request-User`, `Remote-User`, and similar); the app reads the header instead of running its own login. 4. **The app must reject a client-supplied identity header.** Network isolation, only the proxy can reach the app, is the baseline; without it, anyone who reaches the app's port can set `X-Auth-Request-User: admin` directly. The managed-cloud version of this same bypass risk is in [cloud-identity-proxies.md](cloud-identity-proxies.md). ## 2. oauth2-proxy: auth in front of an OIDC/OAuth2 provider Core flags (env vars use the `OAUTH2_PROXY_` prefix): `--provider` (`oidc`, `google`, `github`, and others), `--client-id`/`--client-secret` from the IdP, `--email-domain` (a domain, or `*` for any authenticated user), `--upstream` (the app address, or `static://202` when a forward-auth proxy handles the actual proxying), and `--cookie-secret`, which must be exactly 16, 24, or 32 bytes, optionally base64-encoded: `openssl rand -base64 32 | tr -- '+/' '-_'`. nginx uses `auth_request`, which requires oauth2-proxy's `--reverse-proxy` flag; forwarding the identity headers below also requires `--set-xauthrequest` (it makes oauth2-proxy set `X-Auth-Request-User` and `X-Auth-Request-Email` on its own `/oauth2/auth` response, which the `auth_request_set` lines then read): ```nginx location /oauth2/ { proxy_pass http://127.0.0.1:4180; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Auth-Request-Redirect $request_uri; } location = /oauth2/auth { proxy_pass http://127.0.0.1:4180; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-Uri $request_uri; proxy_set_header Content-Length ""; proxy_pass_request_body off; } location / { auth_request /oauth2/auth; error_page 401 = @oauth2_signin; auth_request_set $user $upstream_http_x_auth_request_user; auth_request_set $email $upstream_http_x_auth_request_email; proxy_set_header X-User $user; proxy_set_header X-Email $email; proxy_pass http://127.0.0.1:3000; } location @oauth2_signin { return 302 /oauth2/sign_in?rd=$scheme://$host$request_uri; } ``` Traefik uses a `forwardAuth` middleware at oauth2-proxy's `/oauth2/auth`, with `--upstream=static://202` and `--reverse-proxy=true` set on oauth2-proxy: ```yaml http: middlewares: oauth-auth: forwardAuth: address: "http://oauth2-proxy:4180/oauth2/auth" trustForwardHeader: true authResponseHeaders: [X-Auth-Request-Access-Token, Authorization] ``` ## 3. Authelia: a portal that does the second factor Authelia is a login portal with built-in TOTP and WebAuthn, sitting behind the proxy rather than replacing it. Per its support page, nginx, Traefik, Caddy (2.5.1+), HAProxy (via a Lua module), Envoy, Skipper, NGINX Proxy Manager, and SWAG are supported; Apache and IIS are documented as having no compatible module and are not supported. nginx calls a dedicated `auth-request` endpoint (its `auth_request` module cannot forward the method and body the way the others' forward-auth middlewares do). That endpoint must be defined; Authelia's own snippets (`authelia-location.conf` and `authelia-authrequest.conf`) show it as: ```nginx resolver 127.0.0.11 valid=30s; set $upstream_authelia http://authelia:9091/api/authz/auth-request; location /internal/authelia/authz { internal; proxy_pass $upstream_authelia; proxy_set_header X-Original-Method $request_method; proxy_set_header X-Original-URL $scheme://$host$request_uri; proxy_set_header X-Forwarded-For $remote_addr; proxy_set_header Content-Length ""; proxy_set_header Connection ""; proxy_pass_request_body off; } # in the protected location block: auth_request /internal/authelia/authz; auth_request_set $redirection_url $upstream_http_location; error_page 401 =302 $redirection_url; ``` nginx needs a way to resolve the `authelia` hostname at request time since `proxy_pass` here targets a variable rather than a static address, so the `resolver` line above (or a matching `upstream` block) is required; Authelia's nginx integration assumes a Docker DNS resolver is available for this, per Authelia's nginx integration guide. Traefik and Caddy call `/api/authz/forward-auth` instead: ```yaml - traefik.http.middlewares.authelia.forwardauth.address=http://authelia:9091/api/authz/forward-auth - traefik.http.middlewares.authelia.forwardauth.authResponseHeaders=Remote-User,Remote-Groups,Remote-Email,Remote-Name ``` ```caddyfile forward_auth authelia:9091 { uri /api/authz/forward-auth copy_headers Remote-User Remote-Groups Remote-Email Remote-Name } ``` Authelia needs its own random session secret and access-control rules (which paths need `one_factor` vs `two_factor`); its second factor is TOTP, WebAuthn/passkeys, or Duo mobile push. ## 4. Pomerium: the proxy is the access layer Pomerium is an identity-aware proxy rather than a sidecar to nginx: it terminates the connection, authenticates the user, and enforces policy in one process. An IdP goes under `idp_provider`, `idp_provider_url`, `idp_client_id`, and `idp_client_secret`; each app is a route carrying its own `policy` (which users, domains, or claims may reach it), rather than a shared middleware bolted onto an existing proxy. Choose Pomerium over the two above when you want routing, TLS, and access control in one process instead of an auth check layered in front of nginx, Traefik, or Caddy. ## 5. MFA and identity source oauth2-proxy's MFA is whatever its OIDC/OAuth provider enforces; Pomerium's is whatever its `idp_provider` enforces; only Authelia enforces a second factor itself. See [mfa.md](mfa.md) (enrolment is not enforcement) and [identity-providers.md](identity-providers.md) for which hosted providers' tiers include MFA. ## Verify ```bash ss -tlnp | grep 3000 # app on 127.0.0.1 only curl -sI http://203.0.113.10:3000/ # from another host: connection refused curl -sI -H 'X-Auth-Request-User: admin' http://203.0.113.10:3000/ # forged header, straight at the app: still connection refused curl -sI https://app.example.com/ # no session: redirected to the sign-in page, or a 401 ``` After a real login through the proxy, confirm a session reaches the app and the app-side log shows the identity header the proxy set, not the forged one above. The forged header is never a bypass because the app is reachable only through the proxy; it is refused at the network level, not read and discarded. ## Common mistakes - The app also listens on a public interface next to the proxy, so a direct request skips authentication entirely. - The app trusts `X-Auth-Request-User` or `Remote-User` from any caller, not only from the proxy. - oauth2-proxy's `--cookie-secret` reused across environments or committed to the repository. - Deploying Authelia behind Apache or IIS, which it does not support. ## Sources (checked September 2026) - oauth2-proxy configuration overview (flags, cookie-secret length): https://oauth2-proxy.github.io/oauth2-proxy/configuration/overview - oauth2-proxy nginx integration: https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/nginx/ - oauth2-proxy Traefik integration: https://oauth2-proxy.github.io/oauth2-proxy/configuration/integrations/traefik/ - Authelia proxy integration introduction: https://www.authelia.com/integration/proxies/introduction/ - Authelia proxy support matrix (Apache and IIS unsupported): https://www.authelia.com/integration/proxies/support/ - Authelia nginx integration: https://www.authelia.com/integration/proxies/nginx/ - Authelia Traefik integration: https://www.authelia.com/integration/proxies/traefik/ - Authelia Caddy integration: https://www.authelia.com/integration/proxies/caddy/ - Authelia second-factor introduction: https://www.authelia.com/configuration/second-factor/introduction/ - Pomerium identity provider settings: https://www.pomerium.com/docs/reference/identity-provider-settings - Pomerium documentation: https://www.pomerium.com/docs ====================================================================== ==> apache.md ====================================================================== # Apache HTTP Server: TLS and authentication Applies to Apache 2.4. Get a certificate first: [free-certificates.md](free-certificates.md) for a public host (note that `certbot --apache` performs steps 1 to 3 of this guide for you), or [self-signed.md](self-signed.md) for internal use. ## 1. Enable the modules ```bash # Debian/Ubuntu sudo a2enmod ssl headers sudo a2ensite default-ssl # or your own :443 vhost file # RHEL/Fedora sudo dnf install mod_ssl httpd-tools ``` ## 2. Configure the HTTPS virtual host ```apache ServerName example.com DocumentRoot /var/www/html SSLEngine on SSLCertificateFile /etc/letsencrypt/live/example.com/fullchain.pem SSLCertificateKeyFile /etc/letsencrypt/live/example.com/privkey.pem # TLS 1.2 minimum. The TLSv1.3 keyword needs Apache 2.4.36+ with OpenSSL 1.1.1+; # on older builds use: SSLProtocol all -SSLv3 -TLSv1 -TLSv1.1 SSLProtocol -all +TLSv1.2 +TLSv1.3 # Send HSTS only once HTTPS is confirmed working Header always set Strict-Transport-Security "max-age=31536000; includeSubDomains" ``` On Apache 2.4.8 and later, `SSLCertificateFile` may contain the certificate plus its chain (certbot's `fullchain.pem`), and `SSLCertificateChainFile` is deprecated. For cipher suites beyond the protocol floor, generate a current list with the [Mozilla SSL Configuration Generator](https://ssl-config.mozilla.org/) rather than copying one from an old tutorial. ## 3. Redirect HTTP to HTTPS ```apache ServerName example.com Redirect permanent / https://example.com/ ``` Keep port 80 serving only this redirect (and ACME HTTP-01 challenges if certbot uses the webroot method). ## 4. Require authentication Application-level login is preferable ([authentication.md](authentication.md)). To gate a whole site or path at the server, use basic authentication over TLS with bcrypt-hashed entries: ```bash sudo htpasswd -B -c /etc/apache2/.htpasswd admin # -c only for the first user ``` ```apache AuthType Basic AuthName "Restricted" AuthUserFile /etc/apache2/.htpasswd Require valid-user ``` For machine-to-machine links, mutual TLS is stronger than passwords: ```apache SSLCACertificateFile /etc/ssl/certs/internal-ca.crt SSLVerifyClient require SSLVerifyDepth 2 ``` Basic authentication is single-factor, and Authelia documents Apache as unsupported for its portal. For human-facing sites, add MFA by making Apache an OIDC client with [mod_auth_openidc](https://github.com/OpenIDC/mod_auth_openidc), with MFA enforced at the identity provider, or by fronting the site with Cloudflare Access; options in [mfa.md](mfa.md). ## 5. Verify ```bash sudo apachectl configtest && sudo systemctl reload apache2 # httpd on RHEL curl -sI http://example.com/ # expect 301 with a https:// Location curl -sI https://example.com/ # expect 200 without -k curl -s https://example.com/ # expect 401 when basic auth is on ``` ## Common mistakes - Serving the application on port 80 next to the HTTPS vhost instead of only redirecting. - Enabling `mod_ssl` without `Header`/HSTS, leaving downgrade open on repeat visits. - World-readable private keys; keep them `0600` and root-owned. - Protecting `/admin` but leaving `/api` open; `Require` rules apply per path, so enumerate what is public. ## Sources (checked September 2026) - Apache SSL/TLS how-to: https://httpd.apache.org/docs/2.4/ssl/ssl_howto.html - Apache authentication how-to: https://httpd.apache.org/docs/2.4/howto/auth.html - Mozilla SSL Configuration Generator: https://ssl-config.mozilla.org/ ====================================================================== ==> nginx.md ====================================================================== # nginx: TLS and authentication Get a certificate first: [free-certificates.md](free-certificates.md) for a public host (note that `certbot --nginx` edits the server block for you), or [self-signed.md](self-signed.md) for internal use. ## 1. HTTPS server block ```nginx server { listen 443 ssl; listen [::]:443 ssl; http2 on; # nginx 1.25.1+; on older versions: listen 443 ssl http2; server_name example.com; ssl_certificate /etc/letsencrypt/live/example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; ssl_prefer_server_ciphers off; # Send HSTS only once HTTPS is confirmed working add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always; location / { proxy_pass http://127.0.0.1:3000; # your app, bound to loopback only proxy_set_header Host $host; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-Proto $scheme; } } ``` For explicit cipher lists, generate them with the [Mozilla SSL Configuration Generator](https://ssl-config.mozilla.org/) instead of copying from old tutorials; the protocol floor above is the part that must not be omitted. ## 2. Redirect HTTP to HTTPS ```nginx server { listen 80; listen [::]:80; server_name example.com; return 301 https://$host$request_uri; } ``` ## 3. Require authentication Application-level login is preferable ([authentication.md](authentication.md)). To gate a site or path at the proxy, use basic authentication over TLS: ```bash sudo apt install apache2-utils # provides htpasswd sudo htpasswd -B -c /etc/nginx/.htpasswd admin ``` ```nginx location / { auth_basic "Restricted"; auth_basic_user_file /etc/nginx/.htpasswd; proxy_pass http://127.0.0.1:3000; } ``` Mutual TLS for machine-to-machine access: ```nginx ssl_client_certificate /etc/ssl/certs/internal-ca.crt; ssl_verify_client on; ``` Basic authentication is single-factor. For human-facing sites, add MFA with the `auth_request` mechanism pointed at an [Authelia](https://www.authelia.com/) or [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy) portal, or front the site with Cloudflare Access; options in [mfa.md](mfa.md). ## 4. Verify ```bash sudo nginx -t && sudo systemctl reload nginx curl -sI http://example.com/ # expect 301 with a https:// Location curl -sI https://example.com/ # expect 200 without -k curl -s https://example.com/api # expect 401/403 without credentials ``` ## Common mistakes - The app still listens on `0.0.0.0:3000` next to the proxy, so the proxy's TLS and auth are bypassed. Bind the app to `127.0.0.1` and confirm with `ss -tlnp`. - `add_header` in a `location` block silently drops headers inherited from `server`; keep HSTS at the `server` level with `always`. - A default `server` block that still serves plain HTTP for unmatched hosts; give the catch-all server the same redirect. - `auth_basic` on `/` but a later `location` (for example `/static`) that re-opens access; `auth_basic off` should be a deliberate exception, not an accident. ## Sources (checked September 2026) - Configuring HTTPS servers: https://nginx.org/en/docs/http/configuring_https_servers.html - ngx_http_auth_basic_module: https://nginx.org/en/docs/http/ngx_http_auth_basic_module.html - Mozilla SSL Configuration Generator: https://ssl-config.mozilla.org/ ====================================================================== ==> lighttpd.md ====================================================================== # lighttpd: TLS and authentication Applies to lighttpd 1.4.56 and later, which disables SSLv2/SSLv3/TLS 1.0/TLS 1.1 by default. Get a certificate first: [free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md). ## 1. Enable TLS ``` server.modules += ( "mod_openssl" ) $SERVER["socket"] == ":443" { ssl.engine = "enable" ssl.pemfile = "/etc/letsencrypt/live/example.com/fullchain.pem" ssl.privkey = "/etc/letsencrypt/live/example.com/privkey.pem" } $SERVER["socket"] == "[::]:443" { ssl.engine = "enable" ssl.pemfile = "/etc/letsencrypt/live/example.com/fullchain.pem" ssl.privkey = "/etc/letsencrypt/live/example.com/privkey.pem" } ``` Version notes: - `ssl.privkey` exists from lighttpd 1.4.53. On older versions, concatenate certificate and key into one file and point `ssl.pemfile` at it. - To set the protocol floor explicitly (recent versions already default to TLS 1.2): ``` ssl.openssl.ssl-conf-cmd = ( "MinProtocol" => "TLSv1.2" ) ``` ## 2. Redirect HTTP to HTTPS `mod_redirect` must be loaded: only `mod_indexfile`, `mod_dirlisting`, and `mod_staticfile` load without being listed in `server.modules`. Per the lighttpd wiki: ``` server.modules += ( "mod_redirect" ) $HTTP["scheme"] == "http" { url.redirect = ("" => "https://${url.authority}${url.path}${qsa}") url.redirect-code = 308 # explicit on versions before 1.4.75 } ``` ## 3. Require authentication Application-level login is preferable ([authentication.md](authentication.md)). Basic authentication at the server, over TLS only: ``` server.modules += ( "mod_auth", "mod_authn_file" ) auth.backend = "htpasswd" auth.backend.htpasswd.userfile = "/etc/lighttpd/lighttpd.user" auth.require = ( "/" => ( "method" => "basic", "realm" => "Restricted", "require" => "valid-user" ) ) ``` Create the user file with Apache's `htpasswd` (package `apache2-utils` or `httpd-tools`). The lighttpd htpasswd backend reads `user:crypt()-hashed-password` entries; check the mod_auth documentation below for the hash algorithms your lighttpd build accepts before choosing an `htpasswd` flag. Basic authentication is single-factor, and lighttpd is absent from Authelia's supported-proxy list. Add MFA by fronting the service with Cloudflare Access ([cloudflare.md](cloudflare.md)) or an MFA-capable proxy; options in [mfa.md](mfa.md). ## 4. Verify ```bash sudo lighttpd -tt -f /etc/lighttpd/lighttpd.conf && sudo systemctl reload lighttpd curl -sI http://example.com/ # expect a redirect to https:// curl -sI https://example.com/ # expect 401 without credentials once auth is on ``` ## Common mistakes - Loading `mod_openssl` but leaving the `:80` socket serving content instead of only the redirect. - Forgetting the `[::]:443` socket, leaving IPv6 clients on plain HTTP. - Pointing `ssl.pemfile` at a certificate without its chain; use `fullchain.pem`. ## Sources (checked September 2026) - lighttpd TLS documentation: https://redmine.lighttpd.net/projects/lighttpd/wiki/Docs_SSL - lighttpd mod_auth documentation: https://redmine.lighttpd.net/projects/lighttpd/wiki/Docs_ModAuth - lighttpd HTTP-to-HTTPS redirect how-to: https://redmine.lighttpd.net/projects/lighttpd/wiki/HowToRedirectHttpToHttps - lighttpd configuration options (`server.modules`, the three modules loaded by default): https://redmine.lighttpd.net/projects/lighttpd/wiki/Docs_ConfigurationOptions ====================================================================== ==> caddy.md ====================================================================== # Caddy: TLS and authentication Caddy 2 obtains, installs, and renews publicly trusted certificates automatically and redirects HTTP to HTTPS by default. For a new deployment with a public domain, it is the shortest correct path to HTTPS: no ACME client, no renewal timer, no redirect block. ## 1. Public site with automatic HTTPS `/etc/caddy/Caddyfile`: ```caddyfile { email admin@example.com # ACME account contact for expiry notices } app.example.com { reverse_proxy 127.0.0.1:3000 } ``` Requirements: the DNS record points at this host, and ports 80 and 443 are reachable from the internet. Start or reload: ```bash sudo systemctl reload caddy ``` That is the whole TLS setup. Certificates come from Let's Encrypt or ZeroSSL and renew automatically. ## 2. Internal hosts without a public domain `tls internal` makes Caddy issue from its own local CA instead of a public one: ```caddyfile app.internal { tls internal reverse_proxy 127.0.0.1:3000 } ``` Clients must trust Caddy's root CA (on the Caddy host itself, `caddy trust` installs it into the local trust store). Distribution of that trust to other machines follows [self-signed.md](self-signed.md). To use certificate files you generated yourself instead: `tls /path/cert.pem /path/key.pem`. ## 3. Require authentication Application-level login is preferable ([authentication.md](authentication.md)). At the proxy, use `basic_auth` (named `basicauth` before Caddy v2.8.0). Hash the password first: ```bash caddy hash-password # prompts, outputs a bcrypt hash ``` ```caddyfile app.example.com { basic_auth { admin $2a$14$REPLACE_WITH_HASH_FROM_caddy_hash-password } reverse_proxy 127.0.0.1:3000 } ``` To protect only part of a site, wrap the directive in a matcher: ```caddyfile @admin path /admin /admin/* basic_auth @admin { admin $2a$14$REPLACE_WITH_HASH } ``` Path matches are exact, and `/admin/*` alone does not match `/admin` itself, so list both forms; multiple paths in one matcher are OR'ed. `basic_auth` is single-factor. For human-facing sites, add MFA with the `forward_auth` directive (Caddy 2.5.1 and later) pointed at an [Authelia](https://www.authelia.com/) portal, or front the site with Cloudflare Access; options in [mfa.md](mfa.md). ## 4. Verify ```bash caddy validate --config /etc/caddy/Caddyfile curl -sI http://app.example.com/ # expect a redirect to https:// curl -sI https://app.example.com/ # expect 401 without credentials once auth is on curl -sS -o /dev/null -w '%{http_code}\n' https://app.example.com/admin # 401 with the @admin matcher curl -sS -o /dev/null -w '%{http_code}\n' https://app.example.com/admin/x # 401 as well ``` ## Common mistakes - The app also listens on a public interface, bypassing Caddy; bind it to `127.0.0.1`. - Blocking port 80 at the firewall: Caddy needs it for the HTTP-01 challenge and for the automatic redirect. - Putting the literal password in the Caddyfile; `basic_auth` takes the bcrypt hash, not the password. ## Sources (checked September 2026) - Automatic HTTPS: https://caddyserver.com/docs/automatic-https - basic_auth directive: https://caddyserver.com/docs/caddyfile/directives/basic_auth - tls directive: https://caddyserver.com/docs/caddyfile/directives/tls - Request matchers (path, wildcards, multiple paths): https://caddyserver.com/docs/caddyfile/matchers ====================================================================== ==> haproxy.md ====================================================================== # HAProxy: TLS termination and authentication Get a certificate first ([free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md)). HAProxy loads the certificate and private key from one combined PEM file: ```bash sudo mkdir -p /etc/haproxy/certs sudo bash -c 'cat /etc/letsencrypt/live/example.com/fullchain.pem \ /etc/letsencrypt/live/example.com/privkey.pem \ > /etc/haproxy/certs/example.com.pem' sudo chmod 600 /etc/haproxy/certs/example.com.pem ``` Re-run the concatenation from a certbot deploy hook so renewals reach HAProxy. ## 1. Terminate TLS and redirect HTTP ```haproxy global ssl-default-bind-options ssl-min-ver TLSv1.2 defaults mode http timeout connect 5s timeout client 30s timeout server 30s frontend web bind :80 bind :443 ssl crt /etc/haproxy/certs/example.com.pem http-request redirect scheme https code 301 unless { ssl_fc } http-response set-header Strict-Transport-Security "max-age=31536000; includeSubDomains" default_backend app backend app server app1 127.0.0.1:3000 check ``` `ssl-min-ver` requires HAProxy 1.8 or later. For explicit cipher lists use the [Mozilla SSL Configuration Generator](https://ssl-config.mozilla.org/). ## 2. Require authentication Application-level login is preferable ([authentication.md](authentication.md)). At the proxy, define a userlist with a crypt(3)-hashed password and demand it: ```bash openssl passwd -6 # prompts, outputs a $6$ SHA-512 crypt hash ``` ```haproxy userlist admins user admin password $6$REPLACE_WITH_HASH backend app http-request auth realm Restricted unless { http_auth(admins) } server app1 127.0.0.1:3000 check ``` Hashed `password` entries rely on the system's crypt(3); `$6$` works on glibc-based Linux. Avoid `insecure-password`, which stores the password in cleartext in the configuration file. For machine-to-machine access, client certificates are stronger: add `verify required ca-file /etc/ssl/certs/internal-ca.crt` to the `bind :443` line. Basic authentication here is single-factor. For human-facing sites, add MFA with an [Authelia](https://www.authelia.com/) portal (HAProxy is supported through Authelia's Lua module) or by fronting the site with Cloudflare Access; options in [mfa.md](mfa.md). ## 3. Verify ```bash sudo haproxy -c -f /etc/haproxy/haproxy.cfg && sudo systemctl reload haproxy curl -sI http://example.com/ # expect 301 with a https:// Location curl -sI https://example.com/ # expect 401 without credentials once auth is on ``` ## Common mistakes - Copying only `fullchain.pem` into the crt file; HAProxy needs the private key in the same PEM. - Renewing the certificate without rebuilding the combined PEM or reloading HAProxy. - Backends reachable directly on `0.0.0.0`, bypassing the proxy; bind them to `127.0.0.1` and confirm with `ss -tlnp`. ## Sources (checked September 2026) - HAProxy documentation: https://www.haproxy.org/ (configuration manual for your installed version) - Mozilla SSL Configuration Generator: https://ssl-config.mozilla.org/ ====================================================================== ==> traefik.md ====================================================================== # Traefik: automatic TLS and authentication middleware Applies to Traefik v2 and v3. Traefik obtains and renews certificates itself through ACME resolvers, which suits container deployments. ## 1. Static configuration: entry points, redirect, ACME `traefik.yml`: ```yaml entryPoints: web: address: ":80" http: redirections: entryPoint: to: websecure scheme: https websecure: address: ":443" certificatesResolvers: letsencrypt: acme: email: admin@example.com storage: /letsencrypt/acme.json tlsChallenge: {} ``` `acme.json` must persist across restarts (volume-mount it) and be mode `600`. The TLS-ALPN challenge above needs port 443 reachable from the internet; use `httpChallenge` (port 80) or a `dnsChallenge` (wildcards, no inbound ports) where that fits better. Raise the protocol floor with a TLS options block in the dynamic configuration: ```yaml tls: options: default: minVersion: VersionTLS12 ``` ## 2. Route a service with TLS (Docker labels) ```yaml services: app: image: yourapp labels: - traefik.enable=true - traefik.http.routers.app.rule=Host(`app.example.com`) - traefik.http.routers.app.entrypoints=websecure - traefik.http.routers.app.tls.certresolver=letsencrypt - traefik.http.services.app.loadbalancer.server.port=3000 ``` Do not also publish the app's port with `ports:`; only Traefik publishes 80 and 443. See [docker.md](docker.md). ## 3. Require authentication Application-level login is preferable ([authentication.md](authentication.md)). At the proxy, attach a basicAuth middleware with bcrypt entries from `htpasswd -nB admin`: ```yaml labels: - traefik.http.middlewares.app-auth.basicauth.users=admin:$$2y$$05$$REPLACE_WITH_HASH - traefik.http.routers.app.middlewares=app-auth ``` In Compose files every `$` in the hash must be doubled to `$$`. The file-provider equivalent, where no escaping is needed: ```yaml http: middlewares: app-auth: basicAuth: users: - "admin:$2y$05$REPLACE_WITH_HASH" ``` basicAuth is single-factor. For human-facing sites, add MFA with the `forwardAuth` middleware pointed at [Authelia](https://www.authelia.com/) or [oauth2-proxy](https://github.com/oauth2-proxy/oauth2-proxy), or front the site with Cloudflare Access; options in [mfa.md](mfa.md). ## 4. Verify ```bash curl -sI http://app.example.com/ # expect a redirect to https:// curl -sI https://app.example.com/ # expect 401 without credentials once auth is on ``` Check the Traefik log for ACME errors on first start; issuance failures otherwise surface as a self-signed "TRAEFIK DEFAULT CERT" in the browser. ## Common mistakes - Enabling the Traefik dashboard (`api.insecure=true` or an unprotected `api@internal` router) on a public entry point; keep it off or behind the auth middleware. - Forgetting to persist `acme.json`, which re-issues certificates on every restart and hits CA rate limits. - Single `$` in Compose basicauth labels, which breaks the hash silently. ## Sources (checked September 2026) - Traefik documentation: https://doc.traefik.io/traefik/ (HTTPS/ACME, routers, and basicAuth middleware sections) ====================================================================== ==> nodejs.md ====================================================================== # Node.js and Express: TLS and authentication Preferred production layout: bind the Node app to `127.0.0.1` and terminate TLS in a reverse proxy ([caddy.md](caddy.md), [nginx.md](nginx.md)) or behind [cloudflare.md](cloudflare.md). Node can also terminate TLS itself, shown below. Get a certificate per [free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md). ## 1. HTTPS directly in Node ```js const https = require('node:https'); const fs = require('node:fs'); const express = require('express'); const app = express(); const options = { key: fs.readFileSync('/etc/ssl/private/server.key'), cert: fs.readFileSync('/etc/ssl/certs/server.crt'), // certificate plus chain }; https.createServer(options, app).listen(443); // Port 80 exists only to redirect require('node:http').createServer((req, res) => { res.writeHead(301, { Location: `https://${req.headers.host}${req.url}` }); res.end(); }).listen(80); ``` Binding ports below 1024 needs root or `CAP_NET_BIND_SERVICE`; running the app as root is a bad trade, which is one more reason to prefer the proxy layout. ## 2. Behind a proxy: tell Express about it ```js app.set('trust proxy', 1); // makes req.secure and secure cookies work behind 1 proxy hop ``` Security headers, including Strict-Transport-Security, via helmet: ```js const helmet = require('helmet'); app.use(helmet()); ``` ## 3. Authentication Follow [authentication.md](authentication.md). The pieces most Node projects need: Password hashing (bcrypt; the `argon2` package is the equivalent alternative): ```js const bcrypt = require('bcrypt'); const hash = await bcrypt.hash(password, 12); const ok = await bcrypt.compare(password, hash); ``` Sessions with hardened cookies (express-session): ```js const session = require('express-session'); app.use(session({ secret: process.env.SESSION_SECRET, // long random value from the environment resave: false, saveUninitialized: false, // store: a production session store (see below); the default MemoryStore is for development only cookie: { secure: true, httpOnly: true, sameSite: 'lax', maxAge: 8 * 60 * 60 * 1000 }, // milliseconds })); ``` Express says the default `MemoryStore` is not designed for production (it leaks memory and does not scale past one process): pass `store:` a production store such as [connect-redis](https://www.npmjs.com/package/connect-redis) or [connect-pg-simple](https://www.npmjs.com/package/connect-pg-simple), and set `cookie.maxAge` so sessions expire, since no maximum age is set by default. Rate-limit the login route (express-rate-limit v7): ```js const rateLimit = require('express-rate-limit'); app.use('/login', rateLimit({ windowMs: 15 * 60 * 1000, limit: 20 })); ``` API keys and tokens come from `process.env`, never from literals in the source. Generate them per [authentication.md](authentication.md) and compare with `crypto.timingSafeEqual` where you check them yourself. MFA: add TOTP with [otplib](https://github.com/yeojz/otplib) plus the [qrcode](https://www.npmjs.com/package/qrcode) package for enrolment QR codes, or front the app with an identity layer; requirements and options in [mfa.md](mfa.md). ## 4. Client-side TLS discipline - Never set `NODE_TLS_REJECT_UNAUTHORIZED=0` and never pass `rejectUnauthorized: false`; both disable certificate validation for every connection. - For an internal CA or self-signed server, point Node at the CA instead: `NODE_EXTRA_CA_CERTS=/path/ca.crt` (see [self-signed.md](self-signed.md)). ## 5. Verify ```bash curl -sI http://example.com/ # expect 301 with a https:// Location curl -sI https://example.com/ # succeeds without -k; shows helmet's headers curl -s https://example.com/api # expect 401/403 without credentials ss -tlnp | grep node # behind a proxy: bound to 127.0.0.1 only ``` ## Sources (checked September 2026) - Node.js HTTPS module: https://nodejs.org/api/https.html - Express behind proxies: https://expressjs.com/en/guide/behind-proxies.html - express-session (MemoryStore warning, cookie.maxAge, compatible stores): https://expressjs.com/en/resources/middleware/session/ - helmet: https://helmetjs.github.io/ ====================================================================== ==> python.md ====================================================================== # Python web apps: TLS and authentication Covers Flask, FastAPI/Uvicorn, Gunicorn, and Django. Preferred production layout: bind the app server to `127.0.0.1` and terminate TLS in a reverse proxy ([caddy.md](caddy.md), [nginx.md](nginx.md)) or behind [cloudflare.md](cloudflare.md). The app servers can also terminate TLS themselves, shown below. Certificates: [free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md). ## 1. TLS per server Flask's built-in server (development only; it is not a production server, TLS or not): ```python app.run(host="127.0.0.1", port=8443, ssl_context=("cert.pem", "key.pem")) # ssl_context="adhoc" generates a throwaway self-signed cert; requires the cryptography package ``` Gunicorn (Flask/Django/WSGI in production): ```bash gunicorn --bind 0.0.0.0:8443 \ --certfile /etc/ssl/certs/server.crt \ --keyfile /etc/ssl/private/server.key \ app:app ``` Uvicorn (FastAPI/ASGI): ```bash uvicorn main:app --host 0.0.0.0 --port 8443 \ --ssl-certfile /etc/ssl/certs/server.crt \ --ssl-keyfile /etc/ssl/private/server.key ``` Bind to `0.0.0.0` only when the process itself terminates TLS and authentication is in place; otherwise keep `127.0.0.1`. ## 2. Django settings for HTTPS ```python SECURE_SSL_REDIRECT = True SECURE_PROXY_SSL_HEADER = ("HTTP_X_FORWARDED_PROTO", "https") # only behind a proxy that sets it SESSION_COOKIE_SECURE = True CSRF_COOKIE_SECURE = True SECURE_HSTS_SECONDS = 31536000 SECURE_HSTS_INCLUDE_SUBDOMAINS = True ``` `SECURE_PROXY_SSL_HEADER` must be set only when a proxy you control always sets that header; otherwise clients can spoof it. Run `python manage.py check --deploy` and fix what it reports. ## 3. Authentication Follow [authentication.md](authentication.md). Framework specifics: - Django's built-in auth already hashes passwords correctly; do not replace it with custom code. - Flask and FastAPI have no user store; hash passwords with `argon2-cffi` or `bcrypt`: ```python from argon2 import PasswordHasher ph = PasswordHasher() hash_ = ph.hash(password) ph.verify(hash_, password) # raises on mismatch ``` - Generate tokens and secrets with the standard library, and load them from the environment: ```python import secrets token = secrets.token_urlsafe(32) ``` - FastAPI's `fastapi.security` classes (`HTTPBearer`, `APIKeyHeader`, `OAuth2AuthorizationCodeBearer`, and so on) extract the credential from the request and declare the OpenAPI security scheme; they validate nothing, and `OpenIdConnect` is documented as a stub that does not implement the scheme or use the discovery URL. Use them to extract the token, then validate it (signature, issuer, audience, expiry) with an OIDC library such as Authlib, and authorise per [oidc-integration.md](oidc-integration.md). - Rate-limit login routes (for example with a proxy-level limit or a library such as slowapi for ASGI apps). - MFA: add TOTP with [pyotp](https://github.com/pyauth/pyotp) plus the [qrcode](https://pypi.org/project/qrcode/) package for enrolment QR codes; [django-otp](https://pypi.org/project/django-otp/) integrates this into Django. Requirements and options in [mfa.md](mfa.md). ## 4. Client-side TLS discipline Never ship `verify=False` (requests/httpx) or `ssl._create_unverified_context`. For an internal CA, point the client at it instead: ```bash export REQUESTS_CA_BUNDLE=/path/ca.crt # requests export SSL_CERT_FILE=/path/ca.crt # httpx and the ssl module ``` ## 5. Verify ```bash curl -sI https://example.com/ # succeeds without -k curl -s https://example.com/api # expect 401/403 without credentials ss -tlnp | grep -E 'gunicorn|uvicorn|python' # behind a proxy: 127.0.0.1 only ``` ## Sources (checked September 2026) - Gunicorn documentation (settings reference): https://docs.gunicorn.org/ - Uvicorn settings reference: https://github.com/encode/uvicorn/blob/master/docs/settings.md - Django deployment checklist: https://docs.djangoproject.com/en/stable/howto/deployment/checklist/ - argon2-cffi: https://argon2-cffi.readthedocs.io/ - Werkzeug serving (`ssl_context="adhoc"` requires cryptography): https://werkzeug.palletsprojects.com/en/stable/serving/ - FastAPI security reference: https://fastapi.tiangolo.com/reference/security/ ; `OpenIdConnect` source (stub warning): https://github.com/fastapi/fastapi/blob/master/fastapi/security/open_id_connect_url.py ====================================================================== ==> docker.md ====================================================================== # Docker and Compose: exposure, TLS, and authentication Containers are where accidental exposure happens most. Two Docker behaviours cause it: 1. `ports: - "3000:3000"` (or `-p 3000:3000`) publishes on `0.0.0.0`, every interface. 2. On Linux, Docker programs iptables/nftables directly, so published ports are reachable **even when UFW or firewalld says the port is blocked**. A `ufw deny 3000` rule does not protect a published container port. ## 1. Publish nothing except the TLS proxy Bind anything that must be reachable from the host to loopback, and give everything else no `ports:` entry at all; containers on the same Compose network reach each other by service name without published ports. ```yaml services: app: build: . # no ports: entry; only the proxy is published db: image: postgres:17 # no ports: entry; the app reaches it at db:5432 on the internal network ``` Where a host-published port is genuinely needed for local access: ```yaml ports: - "127.0.0.1:3000:3000" ``` ## 2. Terminate TLS in one proxy container Caddy is the least configuration ([caddy.md](caddy.md)); nginx ([nginx.md](nginx.md)) and Traefik ([traefik.md](traefik.md)) work the same way. A complete pattern: ```yaml services: app: build: . caddy: image: caddy:2 ports: - "80:80" - "443:443" volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config volumes: caddy_data: caddy_config: ``` `Caddyfile`: ```caddyfile app.example.com { reverse_proxy app:3000 } ``` Caddy obtains and renews the certificate automatically ([free-certificates.md](free-certificates.md) explains the ACME requirements). Hosts with no inbound ports but a domain you can put on Cloudflare should use [cloudflare.md](cloudflare.md); run the `cloudflared` connector as a container and point it at `http://app:3000`. Hosts with no domain at all should use [tailscale.md](tailscale.md), or [self-signed.md](self-signed.md) for internal use. ## 3. Authentication and secrets - The proxy is the natural place for a first authentication gate (basic auth per the proxy guides, or Cloudflare Access); the application still needs its own login for anything multi-user ([authentication.md](authentication.md)). The proxy is also where MFA attaches for human-facing services ([mfa.md](mfa.md)). - Pass secrets at runtime through environment files or Docker/Compose secrets. Never bake them into the image: `ENV API_KEY=...` in a Dockerfile ships the key to every registry the image touches, and `docker history` shows build arguments. - Keep `.env` in `.gitignore`, and run containers as a non-root user (`USER` in the Dockerfile) so a compromised app is not root in the container. - Databases in containers still need their own TLS and authentication when anything outside the Compose network connects: see [postgresql.md](postgresql.md), [mysql.md](mysql.md), [mongodb.md](mongodb.md), and [redis.md](redis.md). ## 4. Verify ```bash docker compose ps # only the proxy shows 0.0.0.0 port bindings ss -tlnp # host view: nothing else on public interfaces curl -sI http://app.example.com/ # expect a redirect to https:// curl -s https://app.example.com/api # expect 401/403 without credentials ``` Test from a second machine on a different network where possible; the UFW bypass means testing the firewall from the host itself proves nothing about published ports. ## Sources (checked September 2026) - Docker packet filtering and firewalls: https://docs.docker.com/engine/network/packet-filtering-firewalls/ - Compose networking: https://docs.docker.com/compose/how-tos/networking/ ====================================================================== ==> kubernetes.md ====================================================================== # Kubernetes: Gateway API TLS and authentication The cluster equivalents of this repository's rules: nothing reaches a workload except through the TLS-terminating entry point (a Gateway API `Gateway`), and no Service becomes public through a casual `type: LoadBalancer` or `NodePort`; the entry point's own Service is the only exception. **If you run ingress-nginx today, migrate.** Earlier versions of this guide built on ingress-nginx. The Kubernetes project retired it in March 2026: per the Kubernetes Steering and Security Response Committees, "there will be no more releases for bug fixes, security patches, or any updates of any kind after the project is retired", and "choosing to remain with Ingress NGINX after its retirement leaves you and your users vulnerable to attack" (as of September 2026; statement linked in Sources). Detect it with cluster-admin permissions: `kubectl get pods --all-namespaces --selector app.kubernetes.io/name=ingress-nginx`. Any pod returned means migration is required; the `nginx.ingress.kubernetes.io/*` annotations die with the controller. Kubernetes documents Gateway API as "the successor to the Ingress API" and links a migration guide from its Gateway API page. The rest of this guide is the Gateway API form of the old rules. ## 1. Gateway API with a maintained implementation Gateway API is a set of CRDs, not part of core Kubernetes; a controller you install implements them. This guide uses [Envoy Gateway](https://gateway.envoyproxy.io/): `helm install eg oci://docker.io/envoyproxy/gateway-helm --version v1.9.1 -n envoy-gateway-system --create-namespace` (version current at the time of writing; the default chart also installs the Gateway API CRDs). The chart does not create a `GatewayClass`; the quickstart applies one separately, and the `Gateway` below refers to it by name, so apply it first and confirm that the controller accepted it: ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: GatewayClass metadata: name: eg spec: controllerName: gateway.envoyproxy.io/gatewayclass-controller ``` ```bash kubectl get gatewayclass eg -o jsonpath='{.status.conditions[?(@.type=="Accepted")].status}' # True ``` Traefik's Gateway API provider (`providers.kubernetesGateway`) and Cilium (`gatewayAPI.enabled=true`, requires kube-proxy replacement) are maintained alternatives, linked in Sources. The `Gateway` is the entry point; keep its HTTP listener only for redirects and ACME challenges: ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: Gateway metadata: name: eg annotations: { cert-manager.io/cluster-issuer: letsencrypt } # section 2 spec: gatewayClassName: eg listeners: - { name: http, protocol: HTTP, port: 80 } - name: https protocol: HTTPS port: 443 hostname: app.example.com tls: { mode: Terminate, certificateRefs: [{ kind: Secret, name: app-tls }] } ``` ```yaml apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: { name: app } spec: parentRefs: [{ name: eg, sectionName: https }] # sectionName binds the route to one listener hostnames: [app.example.com] rules: [{ backendRefs: [{ name: app, port: 80 }] }] # a ClusterIP Service --- apiVersion: gateway.networking.k8s.io/v1 kind: HTTPRoute metadata: { name: app-redirect } spec: parentRefs: [{ name: eg, sectionName: http }] hostnames: [app.example.com] rules: [{ filters: [{ type: RequestRedirect, requestRedirect: { scheme: https, statusCode: 301 } }] }] ``` ## 2. Automatic certificates with cert-manager Install [cert-manager](https://cert-manager.io/docs/) with Gateway API support turned on: `--set config.gatewayAPI.enabled=true` on its Helm chart (cert-manager 1.15 and later per its docs; the Gateway API CRDs must exist before cert-manager starts, or restart its Deployment afterwards). Define an ACME issuer once, with the HTTP-01 solver pointed at the Gateway (adjust `namespace` to where the Gateway lives). The `cert-manager.io/cluster-issuer` (or `cert-manager.io/issuer`) annotation goes on the Gateway, not on routes: cert-manager creates one Certificate per Secret named in the HTTPS listeners, with `dnsNames` taken from each listener's `hostname`, issues into `app-tls`, and renews it. ```yaml apiVersion: cert-manager.io/v1 kind: ClusterIssuer metadata: { name: letsencrypt } spec: acme: email: admin@example.com server: https://acme-v02.api.letsencrypt.org/directory privateKeySecretRef: { name: letsencrypt-account } solvers: - http01: gatewayHTTPRoute: { parentRefs: [{ name: eg, namespace: default, kind: Gateway }] } ``` ## 3. Authentication at the entry point Gateway API defines no authentication filter; each implementation adds its own. Envoy Gateway's `SecurityPolicy` attaches basic auth to a Gateway, HTTPRoute, or GRPCRoute from a Secret holding an htpasswd file. Its docs state that only SHA hashes are supported, which falls short of the bcrypt rule in [authentication.md](authentication.md): treat it as a gate over TLS with long random passwords, and keep the application's own login in place. ```bash htpasswd -cbs .htpasswd admin REPLACE_WITH_LONG_RANDOM_VALUE kubectl create secret generic app-basic-auth --from-file=.htpasswd ``` ```yaml apiVersion: gateway.envoyproxy.io/v1alpha1 kind: SecurityPolicy metadata: { name: app-basic-auth } spec: targetRefs: [{ group: gateway.networking.k8s.io, kind: HTTPRoute, name: app }] basicAuth: { users: { name: app-basic-auth } } ``` For SSO, the same `SecurityPolicy` takes an `oidc` block (`provider.issuer`, `clientID`, a `clientSecret` Secret, `redirectURL`) so Envoy Gateway sends users to an OpenID Connect provider; enforce MFA at that provider ([mfa.md](mfa.md), [identity-providers.md](identity-providers.md)). The portable alternative for any Gateway implementation is oauth2-proxy deployed in the cluster as the route's backend in front of the app, per [mfa.md](mfa.md). Authelia does not proxy traffic; the proxy calls its authorization endpoint, so it needs an implementation with external authorization. On Envoy Gateway the same `SecurityPolicy` takes an `extAuth.http` block whose `backendRefs` point at the Authelia Service and whose `path` is `/api/authz/ext-authz/`, per Authelia's Envoy Gateway page. Publishing through [cloudflare.md](cloudflare.md) is the other option. ## 4. Cluster posture - Expose workloads through the Gateway only; the Service Envoy Gateway creates for it in `envoy-gateway-system` is the one `LoadBalancer` in the cluster. - Databases stay `type: ClusterIP` (the default) and never get a route; NetworkPolicies limit which pods reach them, and their guides' TLS and auth still apply inside the cluster ([postgresql.md](postgresql.md), [mysql.md](mysql.md), [redis.md](redis.md), [mongodb.md](mongodb.md)). - Store credentials in Secrets (or an external secrets operator), not ConfigMaps or env literals in manifests committed to git ([secrets.md](secrets.md)). ## Verify ```bash kubectl get svc -A | grep -E 'NodePort|LoadBalancer' # only the Gateway's Service kubectl get gateway/eg -o jsonpath='{.status.addresses[0].value}' # the public address; DNS points here kubectl get certificate -A # Ready=True curl -sI http://app.example.com/ # 301 to https://app.example.com/ curl -sS -o /dev/null -w '%{http_code}\n' https://app.example.com/ # 401 where basic auth is set ``` ## Sources (checked September 2026) - Ingress NGINX: Statement from the Kubernetes Steering and Security Response Committees (retirement, detection command): https://kubernetes.io/blog/2026/01/29/ingress-nginx-statement/ ; Kubernetes docs, Gateway API (successor to Ingress, migration guide): https://kubernetes.io/docs/concepts/services-networking/gateway/ - Gateway API getting started (CRD install): https://gateway-api.sigs.k8s.io/guides/getting-started/ ; TLS: https://gateway-api.sigs.k8s.io/guides/tls/ ; HTTP routing: https://gateway-api.sigs.k8s.io/guides/http-routing/ ; redirects: https://gateway-api.sigs.k8s.io/guides/http-redirect-rewrite/ - Envoy Gateway: https://gateway.envoyproxy.io/ ; Helm install: https://gateway.envoyproxy.io/docs/install/install-helm/ ; quickstart and its manifest (GatewayClass `controllerName`): https://gateway.envoyproxy.io/docs/tasks/quickstart/ , https://github.com/envoyproxy/gateway/releases/download/v1.9.1/quickstart.yaml - Envoy Gateway tasks, secure gateways (TLS listener): https://gateway.envoyproxy.io/docs/tasks/security/secure-gateways/ ; basic auth: https://gateway.envoyproxy.io/docs/tasks/security/basic-auth/ ; OIDC: https://gateway.envoyproxy.io/docs/tasks/security/oidc/ ; external authorization (`extAuth`): https://gateway.envoyproxy.io/docs/tasks/security/ext-auth/ ; HTTP redirect: https://gateway.envoyproxy.io/docs/tasks/traffic/http-redirect/ - Authelia: proxy integration (the proxy calls the authorization endpoint): https://www.authelia.com/integration/proxies/introduction/ ; Envoy Gateway `SecurityPolicy` example: https://www.authelia.com/integration/kubernetes/envoy/gateway/ - kubectl JSONPath filter syntax: https://kubernetes.io/docs/reference/kubectl/jsonpath/ - cert-manager Gateway API usage (enabling support, annotations): https://cert-manager.io/docs/usage/gateway/ ; ACME HTTP-01 `gatewayHTTPRoute` solver: https://cert-manager.io/docs/configuration/acme/http01/ - Traefik Kubernetes Gateway API provider: https://doc.traefik.io/traefik/reference/install-configuration/providers/kubernetes/kubernetes-gateway/ ; Cilium Gateway API support: https://docs.cilium.io/en/stable/network/servicemesh/gateway-api/gateway-api/ ====================================================================== ==> frontend-frameworks.md ====================================================================== # Full-stack JS frameworks: SvelteKit, Nuxt, Vite SvelteKit, Nuxt, and Vite-based apps built by AI assistants inherit the same traps as [nextjs.md](nextjs.md): a server bind that defaults wide open, a proxy that has to be explicitly trusted before secure cookies and correct origins work, and a public-env prefix that ships anything given it straight to the browser. Each framework also has more than one server entry point (endpoints, server routes, load functions), and a check placed in only one of them leaves the others open, exactly as with Next.js layouts versus Server Actions. ## SvelteKit (`adapter-node`) The built server "will accept connections on `0.0.0.0` using port 3000" by default; override with `HOST` and `PORT`: ```bash HOST=127.0.0.1 PORT=3000 node build ``` Behind a reverse proxy, set `ORIGIN` (for example `ORIGIN=https://app.example.com`) so SvelteKit computes the correct origin for redirects and cookies. `PROTOCOL_HEADER` and `HOST_HEADER` (for example `x-forwarded-proto` and `x-forwarded-host`) let it read the real scheme and host from the proxy; the docs caution to set these only behind a trusted reverse proxy, since an untrusted client could otherwise spoof them. `ADDRESS_HEADER` and `XFF_DEPTH` do the same for the client's real IP. CSRF: `csrf.checkOrigin` (default `true`, deprecated in the current reference) checks the `Origin` header for a POST, PUT, PATCH, or DELETE form submission whose `Content-Type` is `application/x-www-form-urlencoded`, `multipart/form-data`, or `text/plain`, rejecting a mismatch with "Cross-site POST form submissions are forbidden." It does not inspect a JSON body or any other content type reaching a `+server.js` endpoint or form action, so those still need their own origin or session check. The docs point to `csrf.trustedOrigins` instead: an allowlist (default empty) of specific origins permitted to submit forms cross site; only `'*'` trusts every origin, and the docs call that generally not recommended. Public env: only variables prefixed `PUBLIC_` (`env.publicPrefix`) are "statically injected into your bundle at build time" and reachable from `$env/static/public`; anything else throws if imported from client code. Keep secrets in modules SvelteKit treats as server-only, either `$env/static/private` / `$env/dynamic/private`, a `.server.js` filename, or anything under `$lib/server/` ([secrets.md](secrets.md)); SvelteKit statically traces import chains and fails the build if client code imports one, even through a dynamic `import()`. None of this gates a request for you by default: a `+layout.server.js` guard does not protect a sibling `+page.server.js` load function, a form action, or a `+server.js` endpoint reached directly, so each needs its own session check, the same pattern as the Next.js Data Access Layer in [nextjs.md](nextjs.md). The `handle` hook in `hooks.server.js` can enforce access, since it runs on every request and may return a `Response` before `resolve` renders the route, but only when it actually checks the session and blocks; using it just to redirect an unauthenticated page navigation, or just to attach identity onto `event.locals` for other handlers to read, still leaves a directly reached `+server.js` endpoint or form action open. Inside `handle`, `event.url`, `route`, and `params` can reflect the calling page rather than the resource actually being requested, so do not rely on them alone to decide what is being authorized; checking the session there is necessary but not sufficient, since a session check alone does not authorize access to the specific resource, so also enforce that check at the point each resource is served. Wire real authentication with an identity provider or library per [oidc-integration.md](oidc-integration.md), not a client-side redirect alone. ## Nuxt (Nitro server routes) Files under `server/api/`, `server/routes/`, and `server/middleware/` are auto-registered Nitro handlers exported with `defineEventHandler()`, and a handler in `server/middleware/` runs before every other server route. Nuxt ships no built-in authentication. A middleware handler can enforce access if it throws (for example `createError` with a 401 or 403) or otherwise ends the request there; one that only attaches `event.context.auth` for other handlers to read has not enforced anything, and every handler that reads it still has to check it itself: ```ts // server/api/admin.ts: the route checks for itself; the middleware only sets event.context.auth export default defineEventHandler((event) => { if (!event.context.auth?.user) { event.node.res.statusCode = 401 return { message: 'Not authenticated' } } }) ``` `runtimeConfig` splits the same way as Next's env prefix: keys directly on `runtimeConfig` are "only available within server-side"; keys under `runtimeConfig.public` are "also exposed to the client-side." Environment variables override both, using an uppercase `NUXT_` prefix with underscores between key segments: private keys just need `NUXT_` (for example `NUXT_API_SECRET`), public keys need `NUXT_PUBLIC_` (for example `NUXT_PUBLIC_API_BASE`). The docs warn not to "expose runtime config keys to the client-side by either rendering them or passing them to `useState`" even when they are private on the server. ## Vite (dev and preview servers) Both are development tooling, not a production server: the `vite preview` docs say plainly "do not use this as a production server as it's not designed for it." Deploy the built `dist/` behind a real server, CDN, or your platform's hosting per [paas.md](paas.md) instead. `server.host` defaults to `'localhost'`; setting it to `true` or `0.0.0.0` makes the dev server listen on all addresses, including the LAN, which is fine for testing from a phone on a trusted network but should not be left on elsewhere. `server.allowedHosts` defaults to `[]`, which still auto-permits localhost, `.localhost`, and IP addresses; setting it to `true` disables the check entirely, and the docs warn this "allows any website to send requests to your dev server and download your source code and content" (DNS rebinding). Prefer an explicit hostname allowlist over `true`. ## Verify ```bash curl -si https://app.example.com/api/private | head -1 # 401 with no session cookie, on all three frameworks ls build dist .output 2>/dev/null # confirm which output directory your build actually produced grep -rlF "REPLACE_WITH_YOUR_ACTUAL_SECRET_VALUE" build dist .output; echo "exit: $?" # search the built client output for the literal secret value with a # fixed-string match; exit 1 is the goal, exit 2 means a listed # directory did not exist, and grep's own errors are left visible curl -si https://app.example.com/ -H "Host: evil.example.com" | head -1 # a spoofed Host is not trusted ``` A clean grep result here is evidence, not proof: it means the literal value did not match in the directories searched, not that the secret cannot be present in some other form. A bundler could split, encode, or otherwise transform it, so check the exit code and confirm the directory actually exists rather than reading silence alone as clean. Behind a reverse proxy, confirm cookies still carry `Secure` and redirects use an `https://` `Location` once `ORIGIN` (SvelteKit) is set; without it, SvelteKit often builds an `http://` URL even though the browser connection is TLS. `NUXT_PUBLIC_...` is the public runtime-config prefix, exposed straight to the client bundle; it is not a trusted-proxy or secure-cookie setting and does nothing for this check. Nuxt's own trusted-proxy and origin handling come from its deployment preset and hosting platform rather than one documented app-level variable, so verify the equivalent behavior against whichever adapter you deploy with. ## Common mistakes - Treating a SvelteKit `handle` hook that only redirects an unauthenticated page navigation, or a Nuxt `server/middleware/` that only attaches `event.context.auth`, as if it already enforced access, while the `+server.js` endpoint, form action, or `server/api/` route trusts that it happened and skips its own check. - Leaving `server.allowedHosts` at `true`, or `server.host` wide open, past a local demo. - Naming a secret `PUBLIC_...` or `NUXT_PUBLIC_...` out of habit from a genuinely public value. ## Sources (checked September 2026) - SvelteKit adapter-node: https://svelte.dev/docs/kit/adapter-node - SvelteKit server-only modules: https://svelte.dev/docs/kit/server-only-modules - SvelteKit `$env/static/public`: https://svelte.dev/docs/kit/$env-static-public - SvelteKit configuration (`csrf.checkOrigin`, `env.publicPrefix`): https://svelte.dev/docs/kit/configuration - Nuxt runtime config: https://nuxt.com/docs/4.x/guide/going-further/runtime-config - Nuxt server directory structure: https://nuxt.com/docs/4.x/directory-structure/server - Vite server options (`server.host`, `server.allowedHosts`): https://vite.dev/config/server-options - Vite CLI (`vite preview`): https://vite.dev/guide/cli ====================================================================== ==> container-hardening.md ====================================================================== # Containers: non-root, dropped capabilities, read-only root, and network segmentation A model server, chat UI, or proxy is the thing most likely to face untrusted input, so treat its container as compromised eventually and limit what that gets an attacker: not root, not the host's capabilities, not a writable filesystem, and not a route to the rest of the network. ## Docker and Compose - **Run as non-root.** `USER [:]` in the Dockerfile sets the default user for later `RUN` instructions and for `ENTRYPOINT`/`CMD` at runtime (per the Dockerfile reference); a user with no primary group runs with the `root` group, so set both. Compose's `user:` overrides that per service; unset in both places, the container runs as root (per the Compose file reference). - **Read-only root filesystem.** `read_only: true` on a Compose service creates it with a read-only root filesystem; mount a small `tmpfs` for any path the process must write to. - **Drop capabilities.** `cap_drop: [ALL]` removes every Linux capability; add back only the specific one a service needs with `cap_add`. - **No privilege escalation.** `security_opt: [no-new-privileges:true]` (the Compose reference treats `no-new-privileges`, `no-new-privileges=true`, and `no-new-privileges:true` as equivalent) stops a `setuid` binary from gaining more privilege than the process already has. - **Never mount the Docker socket into a container.** `/var/run/docker.sock` is root-equivalent access to the host; a container holding it can start a privileged sibling and escape. ```yaml services: app: build: . user: "10001:10001" read_only: true tmpfs: [/tmp] cap_drop: [ALL] security_opt: [no-new-privileges:true] ``` ## Kubernetes securityContext The same controls, per-Pod or per-container, in the Kubernetes securityContext documentation: ```yaml spec: containers: - name: app securityContext: runAsNonRoot: true runAsUser: 10001 readOnlyRootFilesystem: true allowPrivilegeEscalation: false capabilities: drop: [ALL] seccompProfile: type: RuntimeDefault ``` `runAsNonRoot: true` refuses to start the container if its effective user is root; pin `runAsUser` too. `seccompProfile.type: RuntimeDefault` applies the runtime's default syscall filter instead of running unconfined. Enforce this with Pod Security Admission's `restricted` level, set as the namespace label `pod-security.kubernetes.io/enforce: restricted` (per the Pod Security Standards documentation); the label applies to that namespace only, not the whole cluster. `restricted` requires, among its controls: no privileged containers, no host namespaces or host ports, non-root execution, all capabilities dropped, and a seccomp profile that is not `Unconfined`. A Pod violating any of these is rejected at admission, not merely flagged. The `readOnlyRootFilesystem: true` setting above is a separate, per-container recommendation this guide makes; it is good practice, but it is not one of the controls `restricted` itself requires. ## Network segmentation A NetworkPolicy is additive and does nothing without an enforcing CNI (per the Kubernetes NetworkPolicy documentation; confirm yours enforces it before relying on this). Start default-deny, then allow only the specific path the app needs: ```yaml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: { name: default-deny-all } spec: podSelector: {} policyTypes: [Ingress, Egress] --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: { name: allow-app-egress-to-db } spec: podSelector: { matchLabels: { role: app } } policyTypes: [Egress] egress: - to: [{ namespaceSelector: { matchLabels: { kubernetes.io/metadata.name: kube-system } } }] ports: [{ protocol: UDP, port: 53 }, { protocol: TCP, port: 53 }] - to: [{ podSelector: { matchLabels: { role: db } } }] ports: [{ protocol: TCP, port: 5432 }] --- apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: { name: allow-db-ingress-from-app } spec: podSelector: { matchLabels: { role: db } } policyTypes: [Ingress] ingress: - from: [{ podSelector: { matchLabels: { role: app } } }] ports: [{ protocol: TCP, port: 5432 }] ``` A connection needs both sides to allow it: the egress policy on the source pod and the ingress policy on the destination pod (per the Kubernetes NetworkPolicy documentation). With all three policies applied, an `app` pod can resolve names through the cluster's DNS and reach the `db` pod on port 5432; the `db` pod accepts connections only from pods labeled `role: app` on port 5432; every other path is refused. Databases still need their own TLS and auth on top ([postgresql.md](postgresql.md), [mysql.md](mysql.md), [mongodb.md](mongodb.md), [redis.md](redis.md)); a NetworkPolicy is a layer, not a substitute. ## Verify ```bash docker exec app id # uid is not 0 docker exec app sh -c 'touch /x' # read-only fs: fails kubectl get pod app -o jsonpath='{.spec.containers[0].securityContext}' # resolve the db Service's ClusterIP once and probe that same IP from both pods below; the # role: other pod has no DNS egress under the policies above, so a probe by hostname would fail on # name resolution rather than on the NetworkPolicy, and access by IP could still work even if # the policy were not enforcing anything DBIP=$(kubectl get svc db -o jsonpath='{.spec.clusterIP}') # a probe pod needs its own admission-compliant securityContext under the restricted PSA level, and a # real TCP connect to the db's actual port (a Postgres port does not speak HTTP, so wget cannot test it) SC='{"spec":{"securityContext":{"runAsNonRoot":true,"runAsUser":10001,"seccompProfile":{"type":"RuntimeDefault"}},"containers":[{"name":"probe","image":"busybox:1.36","securityContext":{"allowPrivilegeEscalation":false,"capabilities":{"drop":["ALL"]}},"command":["nc","-z","-w","3",'"$DBIP"',"5432"]}]}}' kubectl run probe-permitted --rm -it --restart=Never --image=busybox:1.36 --labels=role=app \ --overrides="$SC" -- true # from a pod labeled role=app, by IP: must succeed first, proving the path works kubectl run probe-forbidden --rm -it --restart=Never --image=busybox:1.36 --labels=role=other \ --overrides="$SC" -- true # from a pod without that label, same IP: must time out or be refused at TCP, not fail on DNS ``` ## Sources (checked September 2026) - Docker Dockerfile reference (`USER`): https://docs.docker.com/reference/dockerfile/ - Docker Compose file reference (`user`, `read_only`, `cap_add`, `cap_drop`, `security_opt`): https://docs.docker.com/compose/compose-file/ - Kubernetes: Configure a security context for a Pod or Container: https://kubernetes.io/docs/tasks/configure-pod-container/security-context/ - Kubernetes: Pod Security Standards (`restricted` level, Pod Security Admission labels): https://kubernetes.io/docs/concepts/security/pod-security-standards/ - Kubernetes: Network Policies: https://kubernetes.io/docs/concepts/services-networking/network-policies/ ====================================================================== ==> nextjs.md ====================================================================== # Next.js: authentication that actually gates Route Handlers, Server Actions, and data A Next.js app has many entry points: pages, layouts, Route Handlers (`app/**/route.ts`), and every exported Server Action, which the Next.js docs describe as reachable by a direct POST whether or not your UI calls it. A check that lives only in a layout or in `proxy.ts` (the file Next.js 16 renamed from `middleware.ts`) leaves the other entry points open. Next.js supplies cookies and sessions as primitives, not a login system; the login is yours or a library's. ## 1. Bind privately; TLS comes from the platform or the proxy On Vercel and similar platforms the platform terminates TLS; nothing to configure ([paas.md](paas.md)). Self-hosted, `next start` listens on `0.0.0.0:3000` by default; bind to loopback and put a reverse proxy in front, which the Next.js self-hosting guide itself recommends, with TLS and headers per [nodejs.md](nodejs.md), [nginx.md](nginx.md), or [caddy.md](caddy.md). ```bash next build && next start -H 127.0.0.1 -p 3000 # or PORT=3000; PORT cannot be set in .env ``` ## 2. Put the check where the data is The Next.js authentication guide's rule: create a Data Access Layer (DAL) with a `verifySession()` function and call it from every data request, Server Action, and Route Handler. Its reasons, paraphrased from the authentication and data-security guides: - Layouts do not re-render on client navigation, so a session check there does not run on every route change, and a layout that returns `null` does not stop nested segments or Server Actions from running. - Server Actions and Route Handlers get "the same security considerations as public-facing API endpoints"; a page-level check "does not extend to the Server Actions defined within it". - Proxy "should not be your only line of defense": use it for optimistic redirects that read the cookie only, never database lookups. A `matcher` change can silently remove Proxy coverage from a Server Action. ```ts // app/lib/dal.ts import 'server-only' import { cache } from 'react' import { cookies } from 'next/headers' import { redirect } from 'next/navigation' import { decrypt } from '@/app/lib/session' export const verifySession = cache(async () => { const session = await decrypt((await cookies()).get('session')?.value) if (!session?.userId) redirect('/login') return { isAuth: true, userId: session.userId, role: session.role } }) // app/api/admin/route.ts: the handler checks for itself (return 401 here instead of redirecting if you prefer) export async function GET() { const session = await verifySession() if (session.role !== 'admin') return new Response(null, { status: 403 }) } // app/actions.ts: so does every Server Action; the page that renders the form does not count 'use server' export async function deleteRecord(formData: FormData) { const session = await verifySession() if (session.role !== 'admin') return null } ``` Also check ownership of the specific resource inside the DAL (authorization, not only authentication), and return only the fields the client needs. ## 3. Sessions: signed payload, hardened cookie ```bash openssl rand -base64 32 # SESSION_SECRET, set in the environment; never with a NEXT_PUBLIC_ prefix ``` ```ts // app/lib/session.ts (the guide's stateless session, using jose) import 'server-only' import { SignJWT, jwtVerify } from 'jose' import { cookies } from 'next/headers' const key = new TextEncoder().encode(process.env.SESSION_SECRET) export async function createSession(userId: string) { const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000) const session = await new SignJWT({ userId, expiresAt }) .setProtectedHeader({ alg: 'HS256' }).setIssuedAt().setExpirationTime('7d').sign(key) ;(await cookies()).set('session', session, { httpOnly: true, secure: true, expires: expiresAt, sameSite: 'lax', path: '/' }) } ``` `decrypt()` is `jwtVerify(session, key, { algorithms: ['HS256'] })`, returning the payload or `undefined`. Cookies can only be set or deleted in a Server Function or Route Handler; logout is `(await cookies()).delete('session')`. Keep the payload to the user ID and role, no PII. For sensitive operations the guide recommends database sessions verified against the store, not the cookie alone. ## 4. Keep secrets out of the client bundle - `NEXT_PUBLIC_*` variables are inlined into the JavaScript sent to the browser at `next build`; every other variable is server-only. A secret with that prefix is published to every visitor ([secrets.md](secrets.md)). Only the DAL should read `process.env`; `.env*` files stay in `.gitignore`. - More than one self-hosted instance: set `NEXT_SERVER_ACTIONS_ENCRYPTION_KEY` (base64, 16, 24, or 32 decoded bytes) at build so all instances share the Server Action closure key. The docs say not to rely on that encryption to hide secrets. - Server Actions abort when `Origin` does not match `Host` (or `X-Forwarded-Host`). Behind a proxy whose host differs from the public domain, list the public origins in `experimental.serverActions.allowedOrigins` in `next.config.js`. ## 5. Libraries **Auth.js** (https://authjs.dev/): `AUTH_SECRET` is the one mandatory variable; `npx auth secret` writes it to `.env.local`. ```ts // auth.ts import NextAuth from "next-auth" export const { handlers, signIn, signOut, auth } = NextAuth({ providers: [] }) // app/api/auth/[...nextauth]/route.ts import { handlers } from "@/auth" export const { GET, POST } = handlers ``` Providers are configured from `AUTH__ID`, `AUTH__SECRET`, and for OIDC `AUTH__ISSUER`; provider setup and the allowlist check are in [oidc-integration.md](oidc-integration.md) and [identity-providers.md](identity-providers.md). Behind a reverse proxy set `AUTH_TRUST_HOST=true` (automatic on Vercel). Server code calls `const session = await auth()`. Wrapping a Route Handler with `auth(...)` only fills in `req.auth`; nothing refuses the request for you, so the handler must check the session, then authorise, then touch data: ```ts // app/api/notes/route.ts import { NextResponse } from "next/server" import { auth } from "@/auth" // isAllowed and loadNotes are your app's own functions export const GET = auth(async function GET(req) { if (!req.auth) return NextResponse.json({ message: "Not authenticated" }, { status: 401 }) // no automatic 401 if (!isAllowed(req.auth.user)) return NextResponse.json({ message: "Forbidden" }, { status: 403 }) // your allowlist return NextResponse.json(await loadNotes(req.auth.user)) }) ``` `export { auth as proxy }` in `proxy.ts` is the optimistic layer, and Auth.js says not to rely on it exclusively. MFA is not part of this configuration; enforce it at the identity provider ([mfa.md](mfa.md)). **Better Auth** (https://www.better-auth.com/docs/introduction): `BETTER_AUTH_SECRET` (32+ characters, `openssl rand -base64 32`) and `BETTER_AUTH_URL`; the placeholder default secret throws in production. ```ts // lib/auth.ts import { betterAuth } from "better-auth" import { nextCookies } from "better-auth/next-js" import { twoFactor } from "better-auth/plugins" export const auth = betterAuth({ baseURL: "https://app.example.com", // set explicitly; the docs advise against request inference trustedOrigins: ["https://app.example.com"], // cross-origin requests from unlisted origins are rejected emailAndPassword: { enabled: true }, // default false plugins: [twoFactor(), nextCookies()], // nextCookies lets Server Actions set the session cookie }) // app/api/auth/[...all]/route.ts import { toNextJsHandler } from "better-auth/next-js" export const { GET, POST } = toNextJsHandler(auth) ``` Server-side check: `await auth.api.getSession({ headers: await headers() })` in Route Handlers, Server Actions, and Server Components. `getSessionCookie(request)` in `proxy.ts` only proves a cookie exists; Better Auth says to check in each page and route. `twoFactor()` adds TOTP (default), emailed or SMS OTP, and backup codes, with `twoFactorClient()` from `better-auth/client/plugins` on the client; run the schema migration the plugin page documents before enabling it. ## 6. Vercel: Deployment Protection is not user authentication Deployment Protection controls who can open a deployment URL: Vercel Authentication admits Vercel users with access to the project; Passport, Password Protection, and Trusted IPs are Enterprise or paid add-on options. Standard Protection covers previews and generated URLs but not production domains, and protecting production needs Pro or Enterprise (as of September 2026, per the Vercel docs). It gates your team's previews; your users are not Vercel users, so production still needs section 2 ([paas.md](paas.md)). ## Verify ```bash ss -tlnp | grep 3000 # self-hosted: 127.0.0.1 only curl -si https://app.example.com/api/admin | head -1 # 401 (or 302 to /login) with no cookie curl -si https://app.example.com/dashboard | head -1 # 302 to /login, not the page grep -rl "${SESSION_SECRET:0:8}" .next/static # no output: the browser bundle has no secret # Server Action: clear cookies in the browser, submit the form that calls it; it must refuse, not mutate. ``` ## Common mistakes - Checking the session in `app/layout.tsx` or `proxy.ts` only; a Server Action or `route.ts` behind it is still open. - A Proxy `matcher` that excludes `/api`, with nothing in the Route Handlers themselves. - Naming a secret `NEXT_PUBLIC_*`, or treating Vercel Authentication as production login. ## Sources (checked September 2026) - Next.js authentication guide: https://nextjs.org/docs/app/guides/authentication ; data security guide: https://nextjs.org/docs/app/guides/data-security - Next.js `proxy.js`: https://nextjs.org/docs/app/api-reference/file-conventions/proxy ; `route.js`: https://nextjs.org/docs/app/api-reference/file-conventions/route ; `cookies`: https://nextjs.org/docs/app/api-reference/functions/cookies - Next.js environment variables: https://nextjs.org/docs/app/guides/environment-variables ; CLI (`next start` defaults): https://nextjs.org/docs/app/api-reference/cli/next ; self-hosting: https://nextjs.org/docs/app/guides/self-hosting - Auth.js protecting resources (Route Handler `req.auth` check): https://authjs.dev/getting-started/session-management/protecting - Auth.js installation: https://authjs.dev/getting-started/installation ; deployment (`AUTH_SECRET`, `AUTH_TRUST_HOST`, provider variables): https://authjs.dev/getting-started/deployment ; protecting resources: https://authjs.dev/getting-started/session-management/protecting - Better Auth introduction: https://www.better-auth.com/docs/introduction ; installation: https://www.better-auth.com/docs/installation ; options: https://www.better-auth.com/docs/reference/options ; Next.js integration: https://www.better-auth.com/docs/integrations/next ; two-factor plugin: https://www.better-auth.com/docs/plugins/2fa - Vercel Deployment Protection: https://vercel.com/docs/deployment-protection ====================================================================== ==> go.md ====================================================================== # Go: TLS and authentication with net/http Preferred production layout: bind the Go server to `127.0.0.1` and terminate TLS in a reverse proxy ([caddy.md](caddy.md), [nginx.md](nginx.md)) or behind [cloudflare.md](cloudflare.md). `net/http` can also terminate TLS itself, shown below. Certificates: [free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md). ## 1. HTTPS directly in Go ```go go http.ListenAndServe(":80", http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { // port 80 only redirects http.Redirect(w, r, "https://"+r.Host+r.URL.RequestURI(), http.StatusMovedPermanently) })) srv := &http.Server{ Addr: ":443", Handler: mux, ReadHeaderTimeout: 10 * time.Second, TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12}, } log.Fatal(srv.ListenAndServeTLS("/etc/ssl/certs/server.crt", "/etc/ssl/private/server.key")) // cert file: leaf, then intermediates ``` crypto/tls already defaults to a TLS 1.2 minimum (as of September 2026); setting `MinVersion` keeps that true when a config is copied or a default shifts. Binding ports below 1024 needs root or `CAP_NET_BIND_SERVICE`, one more reason to prefer the proxy layout. ## 2. Behind a proxy ```go srv := &http.Server{Addr: "127.0.0.1:8080", Handler: mux, ReadHeaderTimeout: 10 * time.Second} log.Fatal(srv.ListenAndServe()) ``` `http.Server` has no trusted-proxy setting: `r.RemoteAddr` is the proxy, and `X-Forwarded-For` and `X-Forwarded-Proto` are ordinary request headers that anyone who can reach the port could set. Read them only when the listener is loopback-only and the proxy overwrites them, and set `Strict-Transport-Security` and the other headers at the proxy per [headers.md](headers.md). ## 3. Authentication Follow [authentication.md](authentication.md). Password hashing with `golang.org/x/crypto/bcrypt`: ```go hash, err := bcrypt.GenerateFromPassword([]byte(password), 12) // DefaultCost is 10; input above 72 bytes is rejected err = bcrypt.CompareHashAndPassword(hash, []byte(password)) // nil on match ``` `golang.org/x/crypto/argon2` is the alternative: `argon2.IDKey(password, salt, time, memory, threads, keyLen)` returns raw bytes, so you store the random salt and the parameters next to the result. Its documentation carries the RFC 9106 parameter sets (`time=1, memory=2 GiB, threads=4`, or `time=3, memory=64 MiB, threads=4` where memory is short). Session cookie with the flags set: ```go http.SetCookie(w, &http.Cookie{ Name: "session", Value: token, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, }) ``` Rate-limit the login handler with `golang.org/x/time/rate` (one limiter here; key a map of limiters by the proxy-supplied client address for per-client limits): ```go var loginLimiter = rate.NewLimiter(rate.Every(3*time.Second), 5) // about 20 per minute, burst 5 if !loginLimiter.Allow() { http.Error(w, "too many requests", http.StatusTooManyRequests); return } ``` API keys and tokens come from the environment, never from literals in the source; generate them per [authentication.md](authentication.md). SSO: `github.com/coreos/go-oidc/v3/oidc` pairs with `golang.org/x/oauth2`; `oidc.NewProvider(ctx, issuer)` discovers the provider and `provider.Verifier(&oidc.Config{ClientID: clientID})` checks ID token signature, issuer, audience, and expiry. The allowlist checks that follow are in [oidc-integration.md](oidc-integration.md). MFA: app-level TOTP with [pquerna/otp](https://github.com/pquerna/otp), or a fronting identity layer; requirements in [mfa.md](mfa.md). ## 4. Client-side TLS discipline Never set `InsecureSkipVerify: true`; the crypto/tls docs state it accepts any certificate and any host name, which is a machine-in-the-middle position for every connection through that config. For an internal CA, trust the CA instead (see [self-signed.md](self-signed.md) for producing the file): `SSL_CERT_FILE=/path/ca.crt` (or `SSL_CERT_DIR`) overrides the system locations that `x509.SystemCertPool` reads, or add it in code: ```go pool, err := x509.SystemCertPool() pem, err := os.ReadFile("/path/ca.crt") pool.AppendCertsFromPEM(pem) // returns false if nothing parsed client := &http.Client{Transport: &http.Transport{TLSClientConfig: &tls.Config{RootCAs: pool}}} ``` ## 5. Verify ```bash curl -sI http://example.com/ # expect 301 with a https:// Location curl -sI https://example.com/ # succeeds without -k curl -sS -o /dev/null -w '%{http_code}\n' https://example.com/api # 401 or 403 without credentials ss -tlnp | grep REPLACE_WITH_BINARY_NAME # behind a proxy: 127.0.0.1 only ``` ## Sources (checked September 2026) - net/http (Server, ListenAndServeTLS, Cookie, SameSite, Redirect, Transport.TLSClientConfig): https://pkg.go.dev/net/http - crypto/tls (Config.MinVersion, InsecureSkipVerify, RootCAs): https://pkg.go.dev/crypto/tls - crypto/x509 (SystemCertPool, SSL_CERT_FILE, AppendCertsFromPEM): https://pkg.go.dev/crypto/x509 - golang.org/x/crypto/bcrypt: https://pkg.go.dev/golang.org/x/crypto/bcrypt - golang.org/x/crypto/argon2: https://pkg.go.dev/golang.org/x/crypto/argon2 - golang.org/x/time/rate: https://pkg.go.dev/golang.org/x/time/rate - go-oidc: https://pkg.go.dev/github.com/coreos/go-oidc/v3/oidc ====================================================================== ==> dotnet.md ====================================================================== # ASP.NET Core and Kestrel: TLS and authentication Preferred production layout: bind Kestrel to loopback and terminate TLS in a reverse proxy ([caddy.md](caddy.md), [nginx.md](nginx.md)) or behind [cloudflare.md](cloudflare.md). Kestrel can also terminate TLS itself, shown below. Certificates: [free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md). ## 1. HTTPS directly in Kestrel `appsettings.json` with a PKCS12 file (for PEM files, `Path` is the certificate and `KeyPath` the private key; the docs warn against a plaintext `Password` here, so supply it from the environment or a secret store): ```json { "Kestrel": { "Endpoints": { "Http": { "Url": "http://*:80" }, "Https": { "Url": "https://*:443", "Certificate": { "Path": "/etc/ssl/private/server.pfx", "Password": "REPLACE_WITH_LONG_RANDOM_VALUE" } } } } } ``` ```csharp builder.WebHost.ConfigureKestrel(serverOptions => serverOptions.ConfigureHttpsDefaults(listenOptions => listenOptions.SslProtocols = SslProtocols.Tls12 | SslProtocols.Tls13)); // default SslProtocols.None = OS defaults builder.Services.AddHsts(options => { options.MaxAge = TimeSpan.FromDays(365); options.IncludeSubDomains = true; }); if (!app.Environment.IsDevelopment()) { app.UseHsts(); } // browsers cache HSTS; keep it out of development app.UseHttpsRedirection(); // needs the HTTPS port: ASPNETCORE_HTTPS_PORT=443 or options.HttpsPort ``` ## 2. Behind a proxy ```csharp builder.WebHost.ConfigureKestrel(o => o.ListenLocalhost(5000)); // or ASPNETCORE_URLS=http://localhost:5000 builder.Services.Configure(o => { o.ForwardedHeaders = ForwardedHeaders.XForwardedFor | ForwardedHeaders.XForwardedProto; o.KnownProxies.Add(IPAddress.Parse("127.0.0.1")); }); // only listed proxies are trusted app.UseForwardedHeaders(); // first in the pipeline ``` When the proxy already redirects to HTTPS and sends HSTS, leave `UseHttpsRedirection` and `UseHsts` out of the app; per the docs, redirect middleware behind a proxy without forwarded headers loops. Set the remaining security headers at the proxy per [headers.md](headers.md). ## 3. Authentication Follow [authentication.md](authentication.md). ASP.NET Core Identity hashes passwords with PBKDF2 (`PasswordHasherOptions.IterationCount`, default 100,000); keep its hasher rather than writing one. Lockout and cookie settings: ```csharp builder.Services.AddDefaultIdentity(options => { options.Lockout.MaxFailedAccessAttempts = 5; options.Lockout.DefaultLockoutTimeSpan = TimeSpan.FromMinutes(5); options.Lockout.AllowedForNewUsers = true; }).AddEntityFrameworkStores(); builder.Services.ConfigureApplicationCookie(options => { options.Cookie.HttpOnly = true; options.Cookie.SecurePolicy = CookieSecurePolicy.Always; options.Cookie.SameSite = SameSiteMode.Lax; // Strict breaks OAuth2 and OIDC callbacks options.ExpireTimeSpan = TimeSpan.FromHours(8); }); ``` Lockout counts a failure only when the sign-in call asks for it: pass `lockoutOnFailure: true` to `_signInManager.PasswordSignInAsync(email, password, rememberMe, lockoutOnFailure: true)`. With the default `false`, the `Lockout` options above never trigger. Without Identity, the same `Cookie.*` options go on `AddAuthentication(CookieAuthenticationDefaults.AuthenticationScheme).AddCookie(options => ...)`; call `app.UseAuthentication()` then `app.UseAuthorization()` before the `Map*` calls. Deny by default with `AddAuthorizationBuilder().SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build())` and mark public pages `[AllowAnonymous]`. Rate-limit the login route with the built-in middleware (`Microsoft.AspNetCore.RateLimiting`, .NET 7 and later): ```csharp builder.Services.AddRateLimiter(options => { options.RejectionStatusCode = StatusCodes.Status429TooManyRequests; options.AddFixedWindowLimiter("login", o => { o.PermitLimit = 20; o.Window = TimeSpan.FromMinutes(15); o.QueueLimit = 0; }); }); app.UseRateLimiter(); // after UseRouting when policies are per endpoint app.MapPost("/login", LoginHandler).RequireRateLimiting("login"); ``` SSO: the `Microsoft.AspNetCore.Authentication.OpenIdConnect` package adds `.AddOpenIdConnect(options => { options.Authority = ...; options.ClientId = ...; options.ClientSecret = ...; options.ResponseType = OpenIdConnectResponseType.Code; })` next to `.AddCookie()`, with the cookie as `DefaultScheme` and OIDC as `DefaultChallengeScheme`; keep the secret out of `appsettings.json`. Validation and allowlist checks are in [oidc-integration.md](oidc-integration.md), providers in [identity-providers.md](identity-providers.md). MFA: enforce it at the identity provider, or front the app per [mfa.md](mfa.md). ## 4. Client-side TLS discipline - Never assign `HttpClientHandler.DangerousAcceptAnyServerCertificateValidator` or a `ServerCertificateCustomValidationCallback` that returns `true`; both accept any certificate for every request through that handler. - On Linux, .NET reads trusted roots through OpenSSL, so an internal CA is trusted by adding it to the distribution's CA bundle or by pointing `SSL_CERT_FILE` (or `SSL_CERT_DIR`) at a PEM file that starts with `BEGIN CERTIFICATE` (see [self-signed.md](self-signed.md)). ## 5. Verify ```bash curl -sI http://example.com/ # expect 307 or 308 with a https:// Location curl -sI https://example.com/ # succeeds without -k; shows Strict-Transport-Security curl -sS -o /dev/null -w '%{http_code}\n' https://example.com/api # 401 or 403 without credentials ss -tlnp | grep dotnet # behind a proxy: 127.0.0.1 and ::1 only ``` ## Sources (checked September 2026) - Enforce HTTPS (UseHttpsRedirection, UseHsts, HttpsPort): https://learn.microsoft.com/en-us/aspnet/core/security/enforcing-ssl ; Kestrel endpoints (certificate config, ListenLocalhost, SslProtocols): https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/kestrel/endpoints ; proxy servers (ForwardedHeadersOptions, KnownProxies): https://learn.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer - Introduction to Identity (lockout, ConfigureApplicationCookie): https://learn.microsoft.com/en-us/aspnet/core/security/authentication/identity ; PasswordHasherOptions: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.identity.passwordhasheroptions - Cookie authentication without Identity: https://learn.microsoft.com/en-us/aspnet/core/security/authentication/cookie ; CookieSecurePolicy: https://learn.microsoft.com/en-us/dotnet/api/microsoft.aspnetcore.http.cookiesecurepolicy - Rate limiting middleware: https://learn.microsoft.com/en-us/aspnet/core/performance/rate-limit ; OpenID Connect web authentication: https://learn.microsoft.com/en-us/aspnet/core/security/authentication/configure-oidc-web-authentication - DangerousAcceptAnyServerCertificateValidator: https://learn.microsoft.com/en-us/dotnet/api/system.net.http.httpclienthandler.dangerousacceptanyservercertificatevalidator ; trusted roots on Linux: https://learn.microsoft.com/en-us/dotnet/standard/security/cross-platform-cryptography ====================================================================== ==> java.md ====================================================================== # Spring Boot: TLS and authentication Preferred production layout: bind the embedded server to `127.0.0.1` and terminate TLS in a reverse proxy ([caddy.md](caddy.md), [nginx.md](nginx.md)) or behind [cloudflare.md](cloudflare.md). Spring Boot can also terminate TLS itself, shown below. Certificates: [free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md). ## 1. HTTPS directly in Spring Boot PEM certificate and key, or a PKCS12 keystore (the PEM properties are in the current Spring Boot how-to; on an older release, confirm your version's reference lists `server.ssl.certificate` before relying on it): ```properties server.port=8443 server.ssl.certificate=file:/etc/ssl/certs/server.crt server.ssl.certificate-private-key=file:/etc/ssl/private/server.key server.ssl.enabled-protocols=TLSv1.2,TLSv1.3 # PKCS12 keystore instead of the two PEM lines: # server.ssl.key-store=file:/etc/ssl/private/server.p12 # server.ssl.key-store-password=REPLACE_WITH_LONG_RANDOM_VALUE # server.ssl.key-store-type=PKCS12 ``` Spring Boot configures one connector from properties, HTTP or HTTPS, not both; the how-to recommends HTTPS in properties and an HTTP connector added in code if a port 80 redirect is needed. With Spring Security 6.5 or later, `http.redirectToHttps(withDefaults())` in the `SecurityFilterChain` sends requests that arrive over HTTP to HTTPS, and Spring Security writes `Strict-Transport-Security` by default. In the proxy layout, let the proxy do the redirect and skip the second connector. ## 2. Behind a proxy ```properties server.address=127.0.0.1 server.port=8080 server.forward-headers-strategy=NATIVE ``` `NATIVE` lets the embedded server (Tomcat by default) honour `X-Forwarded-For` and `X-Forwarded-Proto`; `FRAMEWORK` uses Spring's `ForwardedHeaderFilter` instead; the default outside supported cloud platforms is `NONE`. With Tomcat, `server.tomcat.remoteip.internal-proxies` restricts which proxy addresses those headers are accepted from, and `server.tomcat.redirect-context-root=false` keeps redirects on HTTPS when TLS ends at the proxy. ## 3. Authentication Follow [authentication.md](authentication.md). With `spring-boot-starter-security` on the classpath, every endpoint requires authentication, form login and HTTP Basic are on, CSRF protection and security headers are on, and a single user `user` with a generated password is logged at startup. That user is for development only; `spring.security.user.name` and `spring.security.user.password` replace it for local use, and production needs a real `UserDetailsService` with this encoder: ```java @Bean PasswordEncoder passwordEncoder() { return PasswordEncoderFactories.createDelegatingPasswordEncoder(); // bcrypt by default, stored as {bcrypt}... } ``` `BCryptPasswordEncoder(strength)` defaults to strength 10; the docs say to tune it to about 1 second per verification. `Argon2PasswordEncoder.defaultsForSpringSecurity_v5_8()` is the alternative. Session cookie flags and lifetime: ```properties server.servlet.session.cookie.secure=true server.servlet.session.cookie.http-only=true server.servlet.session.cookie.same-site=lax server.servlet.session.timeout=30m ``` The Spring Security reference pages checked document no built-in login rate limiter: limit `/login` at the reverse proxy ([nginx.md](nginx.md), [caddy.md](caddy.md)), or count failures and lock the account in your own `UserDetailsService`. SSO: `spring-boot-starter-oauth2-client` plus properties and `http.oauth2Login(withDefaults())`: ```properties spring.security.oauth2.client.registration.sso.client-id=REPLACE_WITH_CLIENT_ID spring.security.oauth2.client.registration.sso.client-secret=${SSO_CLIENT_SECRET} spring.security.oauth2.client.registration.sso.provider=sso spring.security.oauth2.client.registration.sso.scope=openid,profile,email spring.security.oauth2.client.provider.sso.issuer-uri=https://idp.example.com/ ``` The `issuer-uri` drives OpenID Connect discovery, and the `openid` scope is what makes the registration an OpenID Connect login that returns an ID token; without it the login is plain OAuth2. Allowlist and token checks are in [oidc-integration.md](oidc-integration.md), providers in [identity-providers.md](identity-providers.md). MFA: enforce it at the identity provider, or front the app per [mfa.md](mfa.md). ## 4. Client-side TLS discipline - Never install a trust-all `TrustManager` or hostname verifier, and never copy one from an answer that "fixes" a certificate error; it disables validation for the whole JVM client. - For an internal CA, either import it into the JDK truststore, `keytool -importcert -cacerts -alias internal-ca -file /path/ca.crt` (the `cacerts` password defaults to `changeit`; change it), or declare an SSL bundle, `spring.ssl.bundle.pem.internal.truststore.certificate=file:/path/ca.crt`, and apply it to the client: `restClientBuilder.apply(ssl.fromBundle("internal"))` with an injected `RestClientSsl` (`WebClientSsl` for `WebClient`). See [self-signed.md](self-signed.md). ## 5. Verify ```bash curl -sI https://example.com/ # succeeds without -k; shows Strict-Transport-Security curl -sS -o /dev/null -w '%{http_code}\n' https://example.com/api # 401, or 302 to the login page, without credentials ss -tlnp | grep java # behind a proxy: 127.0.0.1 only grep -c "Using generated security password" app.log # must be 0: otherwise the default user is still active ``` ## Sources (checked September 2026) - Spring Boot how-to, embedded web servers (Configure SSL, forward headers, Tomcat proxy settings): https://docs.spring.io/spring-boot/how-to/webserver.html - Spring Boot SSL bundles: https://docs.spring.io/spring-boot/reference/features/ssl.html ; REST clients (applying a bundle): https://docs.spring.io/spring-boot/reference/io/rest-client.html - Spring Boot javadoc, Ssl: https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/web/server/Ssl.html ; ServerProperties (address, forwardHeadersStrategy, servlet.session): https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/autoconfigure/web/ServerProperties.html - Spring Boot javadoc, session Cookie: https://docs.spring.io/spring-boot/3.5/api/java/org/springframework/boot/web/server/Cookie.html ; Spring Boot and Spring Security defaults: https://docs.spring.io/spring-boot/reference/web/spring-security.html - Spring Boot OAuth2 client properties: https://docs.spring.io/spring-boot/reference/security/oauth2.html ; Spring Security OAuth2 login: https://docs.spring.io/spring-security/reference/servlet/oauth2/login/core.html - Spring Security getting started (Boot defaults): https://docs.spring.io/spring-security/reference/servlet/getting-started.html ; password storage: https://docs.spring.io/spring-security/reference/features/authentication/password-storage.html ; redirect to HTTPS and HSTS: https://docs.spring.io/spring-security/reference/servlet/exploits/http.html - keytool (importcert, cacerts): https://docs.oracle.com/en/java/javase/21/docs/specs/man/keytool.html ====================================================================== ==> php.md ====================================================================== # PHP and Laravel: TLS and authentication PHP normally runs behind a web server (Apache with php-fpm or mod_php, nginx or Caddy with php-fpm), so TLS terminates there: follow [apache.md](apache.md), [nginx.md](nginx.md), or [caddy.md](caddy.md) with a certificate from [free-certificates.md](free-certificates.md). The PHP-specific exposures are the php-fpm FastCGI socket (the PHP manual: "An exposed FastCGI endpoint allows arbitrary code execution"), session cookies that PHP ships without `Secure`, `HttpOnly`, or `SameSite` (all three default off), `APP_DEBUG=true` left on in production, and cURL calls with certificate checks turned off. ## 1. Keep php-fpm private `listen` is mandatory per pool. Prefer a Unix socket when the web server is on the same host; with TCP, list the allowed clients, because `listen.allowed_clients` is unset by default and then accepts any address. In containers, never publish the FPM port on the host. ```ini ; /etc/php/*/fpm/pool.d/www.conf listen = /run/php/php-fpm.sock ; access controlled by listen.owner, listen.group, listen.mode (default 0660) ; listen = 127.0.0.1:9000 plus listen.allowed_clients = 127.0.0.1 for the TCP alternative, loopback only ``` ## 2. Plain PHP: sessions, passwords, rate limiting ```ini session.cookie_secure = 1 ; default 0 session.cookie_httponly = 1 ; default 0 session.cookie_samesite = Lax ; default "" (no attribute); Lax or Strict session.use_strict_mode = 1 ; default 0; the manual calls enabling it "mandatory for general session security" ``` Call `session_regenerate_id()` after login and on privilege change. Its `delete_old_session` parameter defaults to `false`; the manual advises against destroying the old session immediately (unstable networks, hijack detection), so expire it with a timestamp instead. Hash with `password_hash($password, PASSWORD_DEFAULT)` (bcrypt at the time of writing; `PASSWORD_ARGON2ID` needs PHP built with Argon2), check with `password_verify()`, and upgrade old hashes when `password_needs_rehash()` says so. `PASSWORD_DEFAULT` is designed to change over time, so store the hash in a column that can grow past 60 bytes (the manual suggests 255). Plain PHP has no login rate limiter; apply one at the proxy or with fail2ban per [authentication.md](authentication.md). MFA in plain PHP means a TOTP library or an identity layer in front, per [mfa.md](mfa.md). ## 3. Laravel ```ini APP_ENV=production # .env stays out of source control APP_DEBUG=false # true in production exposes configuration values to end users APP_URL=https://app.example.com APP_KEY= # php artisan key:generate; list old keys in APP_PREVIOUS_KEYS when rotating SESSION_SECURE_COOKIE=true # config/session.php 'secure' has no default SESSION_HTTP_ONLY=true # default true SESSION_SAME_SITE=lax # default lax; strict for admin-only apps ``` Behind a proxy, name it in `bootstrap/app.php` (Laravel 12.x docs; check the docs for your version) so `url()`, `request()->secure()`, and secure cookies see HTTPS: ```php ->withMiddleware(function (Middleware $middleware): void { $middleware->trustProxies(at: ['127.0.0.1', '10.0.0.0/8']); // '*' only when the app is unreachable except through the proxy }) ``` Force HTTPS in generated URLs with `URL::forceHttps()` (or `URL::forceScheme('https')`) in a service provider's `boot()` method. Passwords: `Hash::make()` and `Hash::check()` use bcrypt by default; `HASH_DRIVER=argon2id` switches the driver, `Hash::needsRehash()` upgrades old hashes, and `Hash::check()` rejects hashes made with a different algorithm unless `HASH_VERIFY=false`. Rate-limit the login route in `AppServiceProvider::boot()` and attach it with the `throttle` middleware: ```php RateLimiter::for('login', fn (Request $request) => Limit::perMinute(5)->by($request->email.$request->ip())); Route::post('/login', ...)->middleware('throttle:login'); ``` The 12.x starter kits (React, Vue, Svelte, Livewire) authenticate through Laravel Fortify, which throttles login by username plus IP, regenerates the session ID on login, and ships TOTP two-factor authentication with recovery codes: `Features::twoFactorAuthentication(['confirm' => true, 'confirmPassword' => true])` in `config/fortify.php`. Fortify alone (`composer require laravel/fortify`, `php artisan fortify:install`) gives the same backend without views. Laravel Socialite handles OAuth login for Google, GitHub, GitLab, Slack, and others; OpenID Connect providers come through the community Socialite Providers packages. After the callback, apply the allowlist and token checks in [oidc-integration.md](oidc-integration.md), and keep the `redirect` in `config/services.php` on `https://`. ## 4. Client-side TLS discipline ```php curl_setopt($ch, CURLOPT_SSL_VERIFYPEER, true); // the default; never set false curl_setopt($ch, CURLOPT_SSL_VERIFYHOST, 2); // the default; keep 2 in production curl_setopt($ch, CURLOPT_CAINFO, '/etc/ssl/certs/internal-ca.pem'); // internal CA instead of disabling checks ``` ## Verify ```bash ss -xlnp | grep php-fpm # Unix socket; with TCP, ss -tlnp shows 127.0.0.1:9000 only curl -sI https://app.example.com/ # succeeds without -k curl -sI https://app.example.com/login | grep -i set-cookie # secure; httponly; samesite=lax ``` ## Sources (checked September 2026) - PHP FPM configuration (`listen`, `listen.allowed_clients`, `listen.owner`): https://www.php.net/manual/en/install.fpm.configuration.php - PHP session runtime configuration: https://www.php.net/manual/en/session.configuration.php - PHP `session_regenerate_id()`: https://www.php.net/manual/en/function.session-regenerate-id.php - PHP `password_hash()`: https://www.php.net/manual/en/function.password-hash.php - PHP cURL constants (`CURLOPT_SSL_VERIFYPEER`, `CURLOPT_SSL_VERIFYHOST`, `CURLOPT_CAINFO`): https://www.php.net/manual/en/curl.constants.php - Laravel 12.x encryption (`APP_KEY`, `key:generate`, `APP_PREVIOUS_KEYS`): https://laravel.com/docs/12.x/encryption - Laravel 12.x requests (trusted proxies, trusted hosts): https://laravel.com/docs/12.x/requests - Laravel 12.x `config/session.php` defaults: https://github.com/laravel/laravel/blob/12.x/config/session.php - Laravel 12.x `UrlGenerator::forceHttps()` and `forceScheme()`: https://api.laravel.com/docs/12.x/Illuminate/Routing/UrlGenerator.html - Laravel 12.x hashing: https://laravel.com/docs/12.x/hashing - Laravel 12.x routing (rate limiting): https://laravel.com/docs/12.x/routing - Laravel 12.x starter kits (Fortify, two-factor, rate limiting): https://laravel.com/docs/12.x/starter-kits - Laravel 12.x Fortify: https://laravel.com/docs/12.x/fortify - Laravel 12.x Socialite: https://laravel.com/docs/12.x/socialite - Laravel 12.x deployment (nginx example, `APP_DEBUG`): https://laravel.com/docs/12.x/deployment ====================================================================== ==> ruby.md ====================================================================== # Ruby on Rails and Puma: TLS and authentication Puma's default bind is `tcp://[::]:9292` (or `tcp://0.0.0.0:9292` without IPv6): every interface, plain HTTP. Rails encrypts its session cookie but still sends it in clear unless HTTPS is enforced. Preferred layout: bind Puma to loopback and terminate TLS in a reverse proxy ([nginx.md](nginx.md), [caddy.md](caddy.md), [apache.md](apache.md)) or behind [cloudflare.md](cloudflare.md), with a certificate from [free-certificates.md](free-certificates.md). Puma can also terminate TLS itself, shown below. ## 1. Bind Puma privately ```ruby # config/puma.rb bind "tcp://127.0.0.1:3000" # or: bind "unix:///var/run/puma.sock" # Puma terminating TLS itself (only when no proxy is in front): # bind "ssl://0.0.0.0:8443?key=/etc/ssl/private/server.key&cert=/etc/ssl/certs/server.crt" ``` `bind` accepts only `tcp://`, `unix://`, and `ssl://` URIs. The `ssl://` query string also takes `ca=` and `verify_mode=` for client certificates ([machine-auth.md](machine-auth.md)). ## 2. Rails behind the proxy ```ruby # config/environments/production.rb config.force_ssl = true # ActionDispatch::SSL: HTTPS redirect, HSTS per config.ssl_options (default hsts: { subdomains: true }) config.assume_ssl = true # Rails 7.1+: the proxy terminated TLS and forwards plain HTTP config.hosts << "app.example.com" # Host header allowlist (DNS rebinding) config.session_store :cookie_store, key: "_app_session", secure: true, httponly: true, same_site: :lax, expire_after: 14.days ``` `config.action_dispatch.trusted_proxies` already covers loopback, private, and link-local ranges, so a same-host proxy needs nothing more. A proxy on a public address must be listed as an enumerable, for example `config.action_dispatch.trusted_proxies = [IPAddr.new("203.0.113.10")]`; a single value is not supported. Without any proxy, `X-Forwarded-For` is client-controlled, so do not trust `request.remote_ip` for rate limits or allowlists. `config.action_dispatch.cookies_same_site_protection` defaults to `:lax` from `load_defaults 6.1`. ## 3. Authentication Follow [authentication.md](authentication.md). Rails specifics: - Rails 8.0 and later: `bin/rails generate authentication` "Generates a basic authentication system with users, sessions, and password reset", built on `has_secure_password`. Prefer it to hand-rolled login code. - `has_secure_password` needs `gem "bcrypt", "~> 3.1.7"` and a `password_digest` column; `user.authenticate(password)` returns the user or `false`. It validates presence and the 72-byte bcrypt limit; add your own minimum length. - Rate-limit login in the controller (needs an `ActiveSupport::Cache` store; defaults to `config.action_controller.cache_store`): ```ruby class SessionsController < ApplicationController rate_limit to: 10, within: 3.minutes, only: :create end ``` - For path-level throttles and blocklists, [Rack::Attack](https://github.com/rack/rack-attack) (`gem "rack-attack"`, configured in `config/initializers/rack_attack.rb`; its railtie adds the middleware in Rails). - Secrets live in `config/credentials.yml.enc`, edited with `bin/rails credentials:edit`; the decryption key is `config/master.key` (Rails adds it to `.gitignore`) or `ENV["RAILS_MASTER_KEY"]`, which takes precedence. Set `config.require_master_key = true` so a deployment without the key refuses to boot instead of running half-configured. - MFA: Rails has no built-in second factor. Add TOTP in the app or front it with an identity layer; options in [mfa.md](mfa.md). OIDC login follows [oidc-integration.md](oidc-integration.md). ## 4. Client-side TLS discipline ```ruby Net::HTTP.start("api.example.com", 443, use_ssl: true, # verify_mode defaults to OpenSSL::SSL::VERIFY_PEER ca_file: "/etc/ssl/certs/internal-ca.pem") do |http| # internal CA; or set SSL_CERT_FILE in the environment http.request(Net::HTTP::Get.new("/")) end ``` `Net::HTTP.start` takes `use_ssl`, `ca_file`, `verify_mode`, and the other SSL settings in its options hash and applies them when it opens the connection. Assigning `http.ca_file` inside the block comes after the handshake and does not affect it; to set attributes on an instance, use `Net::HTTP.new` and set them before calling `start`. Never set `verify_mode = OpenSSL::SSL::VERIFY_NONE`. Ruby's default SSL context loads the system store through `set_default_paths`, and OpenSSL reads `SSL_CERT_FILE` and `SSL_CERT_DIR` to locate that store, so an internal CA goes there (see [self-signed.md](self-signed.md)) rather than into a disabled check. ## Verify ```bash ss -tlnp | grep -E 'puma|ruby' # 127.0.0.1:3000 only curl -sI http://app.example.com/ # 301 to https:// (force_ssl) curl -sI https://app.example.com/ | grep -iE 'strict-transport|set-cookie' # HSTS; secure; httponly; samesite=lax curl -s -o /dev/null -w '%{http_code}\n' https://app.example.com/dashboard # 302 to login, or 401 git ls-files config/master.key # prints nothing ``` ## Sources (checked September 2026) - Puma README (binding): https://github.com/puma/puma/blob/master/README.md - Puma DSL (`bind`, `ssl_bind`, default bind): https://github.com/puma/puma/blob/master/lib/puma/dsl.rb - Puma configuration defaults (`tcp://[::]:9292`): https://github.com/puma/puma/blob/master/lib/puma/configuration.rb - Rails configuring guide (`force_ssl`, `assume_ssl`, `ssl_options`, `hosts`, `session_store`, `cookies_same_site_protection`, `require_master_key`): https://guides.rubyonrails.org/configuring.html - Action Pack 7.1 changelog (`ActionDispatch::AssumeSSL`): https://github.com/rails/rails/blob/7-1-stable/actionpack/CHANGELOG.md - `ActionDispatch::RemoteIp` (trusted proxies, spoofing warning): https://api.rubyonrails.org/classes/ActionDispatch/RemoteIp.html - `ActionDispatch::Session::CookieStore` options: https://api.rubyonrails.org/classes/ActionDispatch/Session/CookieStore.html - Rails security guide (authentication generator, `has_secure_password`, `rate_limit`, credentials): https://guides.rubyonrails.org/security.html - `has_secure_password`: https://api.rubyonrails.org/classes/ActiveModel/SecurePassword/ClassMethods.html - `ActionController::RateLimiting`: https://api.rubyonrails.org/classes/ActionController/RateLimiting/ClassMethods.html - `bin/rails credentials:help` text (`master.key`, `RAILS_MASTER_KEY`): https://github.com/rails/rails/blob/main/railties/lib/rails/commands/credentials/USAGE - Rack::Attack README: https://github.com/rack/rack-attack - Net::HTTP source (`verify_mode`, `ca_file`, `VERIFY_PEER` default): https://github.com/ruby/net-http/blob/master/lib/net/http.rb - Ruby OpenSSL `SSLContext` defaults (`DEFAULT_CERT_STORE.set_default_paths`, `VERIFY_PEER`): https://github.com/ruby/openssl/blob/master/lib/openssl/ssl.rb - OpenSSL environment variables (`SSL_CERT_FILE`, `SSL_CERT_DIR`): https://docs.openssl.org/master/man7/openssl-env/ ====================================================================== ==> host.md ====================================================================== # Host baseline: SSH, firewall, updates Every guide in this repository secures a service; this one secures the machine under them. Apply it once per host before exposing anything. ## 1. SSH: keys only, no root login Add your public key to `~/.ssh/authorized_keys` and confirm that key login works **before** disabling passwords. Keep the current session open while testing changes. `/etc/ssh/sshd_config` (or a file in `/etc/ssh/sshd_config.d/`): ``` PasswordAuthentication no KbdInteractiveAuthentication no PermitRootLogin no PubkeyAuthentication yes ``` ```bash sudo sshd -t && sudo systemctl reload ssh # sshd on RHEL-family systems ``` Add a second factor for SSH per [mfa.md](mfa.md): TOTP via [google-authenticator-libpam](https://github.com/google/google-authenticator-libpam) or push approval via Duo's `pam_duo`. Both run through PAM's keyboard-interactive path, which the block above turns off. When adding PAM MFA, change the block to: ``` PasswordAuthentication no KbdInteractiveAuthentication yes UsePAM yes AuthenticationMethods publickey,keyboard-interactive PermitRootLogin no PubkeyAuthentication yes ``` `AuthenticationMethods` with a comma-separated list requires every method in it, so a key alone is no longer enough. `PasswordAuthentication no` stays: the code prompt is keyboard-interactive, not password. Older sshd_config files spell `KbdInteractiveAuthentication` as `ChallengeResponseAuthentication`; set that one to `yes` where it is the one present. ## 2. Firewall: default deny inbound ```bash # Debian/Ubuntu (ufw) sudo ufw default deny incoming sudo ufw default allow outgoing sudo ufw allow OpenSSH sudo ufw allow 80/tcp sudo ufw allow 443/tcp sudo ufw enable ``` RHEL-family systems use firewalld (`firewall-cmd --permanent --add-service=https` and so on) with the same posture. Open only the ports the TLS-terminating layer needs; databases and app servers stay unreachable from outside per their guides. Docker-published ports bypass ufw entirely; see [docker.md](docker.md) before relying on the firewall. ## 3. Brute-force protection and updates - fail2ban ([github.com/fail2ban/fail2ban](https://github.com/fail2ban/fail2ban)) or CrowdSec ([crowdsec.net](https://www.crowdsec.net/)) bans repeated authentication failures against SSH and login panels. - Automate security patches: `unattended-upgrades` on Debian/Ubuntu, `dnf-automatic` on RHEL-family systems. ## 4. Verify ```bash ss -tlnp # only intended listeners, on intended addresses sudo ufw status verbose # default deny incoming, minimal allow list ssh -o PreferredAuthentications=password user@host # expect: Permission denied ssh user@host # with PAM MFA: the key is accepted, then the code prompt appears before a shell ``` Run the SSH test from a second terminal before closing your working session. ## Sources (checked September 2026) - OpenSSH sshd_config manual: https://man.openbsd.org/sshd_config - fail2ban: https://github.com/fail2ban/fail2ban - CrowdSec: https://www.crowdsec.net/ - google-authenticator-libpam: https://github.com/google/google-authenticator-libpam - Duo Unix (`UsePAM`, `KbdInteractiveAuthentication`, `AuthenticationMethods publickey,keyboard-interactive`): https://duo.com/docs/duounix ====================================================================== ==> cloud-firewalls.md ====================================================================== # Cloud firewalls: security groups and network rules On AWS (security groups), Google Cloud (VPC firewall rules), and Azure (network security groups), the recurring hole is one rule wide open to the world: `0.0.0.0/0` (or `::/0`) on a database, admin, or SSH port, added once to unblock a remote connection and never removed. ## Rules 1. **Public means 80/443 on the TLS layer, nothing else.** Only the load balancer, reverse proxy, or tunnel endpoint accepts traffic from `0.0.0.0/0`, and only on 80 (redirect) and 443. 2. **Databases and internal services accept traffic from private sources only**: the application's security group, subnet, or VPC, never the internet. The per-database guides' TLS and auth still apply on top; the firewall is a layer, not the control. 3. **SSH is not public.** Restrict port 22 to your addresses, or remove the inbound rule entirely and use the provider's brokered access (AWS SSM Session Manager, GCP Identity-Aware Proxy, Azure Bastion) or a tailnet ([tailscale.md](tailscale.md)). Then harden the host per [host.md](host.md). 4. **Default deny, explicit allow.** Start from no inbound rules and add the minimum; review rules whenever a service is retired. Reference security-group IDs rather than IP ranges where the provider supports it, so app-to-database access survives IP changes without widening. 5. **Both layers matter on VMs running Docker**: the cloud firewall and the host's rules, remembering that published container ports bypass host UFW ([docker.md](docker.md)). ## Verify - Provider console or CLI: list rules allowing `0.0.0.0/0` and confirm that each one is 80/443 on the front layer, nothing else. - From an outside network: `for p in 22 3306 5432 6379 27017; do nc -vz -w 3 203.0.113.10 "$p"; done # every line must fail to connect`. Each port must report a refused or timed-out connection; a usage error from `nc` (some netcat variants take one port or a range per invocation) is not a passing result. - An external scan of the public IP (for example with nmap, against your own infrastructure only) shows only the intended ports. ## Sources (checked September 2026) - AWS VPC and security groups: https://docs.aws.amazon.com/vpc/ - Google Cloud VPC firewall rules: https://cloud.google.com/vpc/docs - Azure virtual network security: https://learn.microsoft.com/en-us/azure/virtual-network/ ====================================================================== ==> paas.md ====================================================================== # PaaS platforms: what the platform does, what stays yours On Render, Fly.io, Railway, Vercel, Heroku, and similar platforms, TLS is not your problem: the platform terminates HTTPS and manages certificates, including for custom domains. Do not bolt certbot or a reverse proxy onto a PaaS app, and do not apply this repository's server-TLS guides there. What stays yours: ## 1. Authentication: entirely yours The platform does not authenticate your application's users. Some platforms protect the deployment itself: Vercel Deployment Protection, for example, can require a Vercel login to open preview and deployment URLs on every plan, and production domains on Pro and above, with Password Protection as a paid add-on (per https://vercel.com/docs/deployment-protection as of September 2026). That gate keeps the public out of a preview; it is not your app's login. Every non-public endpoint still needs login or keys per [authentication.md](authentication.md), MFA where viable per [mfa.md](mfa.md), and a hosted provider makes both easy ([identity-providers.md](identity-providers.md)). "It is on Vercel" changes nothing about an open `/api/admin`. ## 2. Secrets: use the platform's store Each platform provides environment/secret configuration. Set secrets there; never commit `.env` files ([secrets.md](secrets.md)). Rotate anything that ever appeared in the repository, build logs, or client bundles. Public frontend frameworks compile some env vars into the client (for example `NEXT_PUBLIC_`-prefixed values); only put genuinely public values in those. ## 3. Enforce HTTPS and correct proxy awareness - Redirect HTTP to HTTPS where the platform offers a toggle, or in the app (checking the platform's forwarded-protocol header). - Behind the platform proxy, configure the framework accordingly (`trust proxy` in Express per [nodejs.md](nodejs.md), `SECURE_PROXY_SSL_HEADER` in Django per [python.md](python.md)) so secure cookies and redirects behave. - Bind to the port the platform injects (commonly a `PORT` variable) and nothing else; do not open extra listeners. ## 4. Databases attached to PaaS apps Managed databases from these platforms come with TLS endpoints; require verified TLS in the connection string per the database guides ([postgresql.md](postgresql.md), [mysql.md](mysql.md)) and keep the credentials in the platform's secret store. Databases you run yourself elsewhere follow their own guides plus [cloud-firewalls.md](cloud-firewalls.md). ## 5. Hugging Face Spaces A new Space is public by default: anyone can view the source and reach the running app. Visibility can be changed to "protected" (the source stays private to the owner and collaborators, but the running app is still public through its embed URL or custom domain) or "private" (both the source and the running app are restricted to the owner and collaborators); protected visibility requires a paid plan, PRO for personal accounts or Team/Enterprise for organizations, at the time of writing. Space secrets belong in the Settings tab's secrets store, never in the repository or its README metadata; anything set as a "variable" instead of a "secret" is still publicly readable. That secrets store is not a safe place for backend or provider credentials on a Static Space: Hugging Face exposes both variables and secrets to client-side JavaScript there, through `window.huggingface.variables`, so anything placed in a Static Space's secrets still reaches the browser. Keep server-side credentials in a server-side service, such as a Docker or Gradio Space or a separate backend, instead. Dev Mode opens an SSH and VS Code endpoint straight into the running container, which is materially more access than the app itself, so treat an unattended Dev Mode session the same as any other exposed admin path. For serverless GPU platforms, see [gpu-clouds.md](gpu-clouds.md). ## 6. Verify ```bash curl -sI http://app.example.com/ # platform redirects to https curl -s https://app.example.com/api/... # 401/403 without credentials # Repository scan per secrets.md comes back clean; client bundle contains no private keys. ``` ## Sources (checked September 2026) - Render: https://render.com/docs ; Fly.io: https://fly.io/docs ; Vercel: https://vercel.com/docs (each documents managed TLS and environment configuration; consult your platform's pages for the exact toggles) - Vercel Deployment Protection: https://vercel.com/docs/deployment-protection - Hugging Face Spaces: https://huggingface.co/docs/hub/spaces-overview and https://huggingface.co/docs/hub/spaces-config-reference ====================================================================== ==> egress-metadata.md ====================================================================== # Egress control and cloud metadata: keeping an agent from exfiltrating credentials An AI agent, RAG fetcher, or webhook handler that retrieves URLs can be steered by a prompt injection into requesting the cloud metadata endpoint or an internal service instead of the URL it was meant to fetch. Making the fetcher refuse that request is application security; this guide covers the deployment-side backstop, metadata hardening so the endpoint rejects an unqualified request, plus default-deny egress so the request never leaves the workload at all. ## AWS: require IMDSv2 and cap the hop limit The instance metadata service listens on `169.254.169.254`. IMDSv2 requires a session token obtained with a `PUT` before any `GET` succeeds. By default, the response to that `PUT`, the token itself, has a hop limit of 1 at the IP protocol level: the token cannot travel more than one network hop back to the requester (per the AWS instance metadata service documentation). A containerized application sitting one hop from the host, for example behind the container network's own routing, will not receive the token and so cannot complete an IMDSv2 request; this is a limit on the token response reaching that far, not a guarantee that no proxy anywhere can reach the metadata service itself. Enforce IMDSv2 and keep the hop limit at 1: ```bash aws ec2 modify-instance-metadata-options \ --instance-id i-0123456789abcdef0 \ --http-tokens required \ --http-put-response-hop-limit 1 \ --http-endpoint enabled ``` With `--http-tokens required`, a request without a valid token receives a 401 from the service itself. ## GCP and Azure: a required header, but block the address anyway GCP's metadata server answers at `metadata.google.internal` or `169.254.169.254` and requires a `Metadata-Flavor: Google` header on every request; Google states the request and response never leave the physical host. Azure's Instance Metadata Service listens at the same non-routable address, `169.254.169.254`, reachable only from within the VM, and requires a `Metadata: true` header, rejecting any request that also carries an `X-Forwarded-For` header. Both header checks stop a naive `curl`, but neither stops a fetch that has been steered into adding the header, so block `169.254.169.254` from workloads that have no legitimate reason to reach it, the same as any other internal address. ## Default-deny egress - **Cloud firewall / security group egress rules** (the inbound side of the same tools is in [cloud-firewalls.md](cloud-firewalls.md)): default outbound rules on most providers allow everything out; add explicit egress rules that permit only DNS and the specific provider APIs the application calls, and deny the rest. On AWS, security groups do not filter traffic to or from the instance metadata address, `169.254.169.254` (per the security groups documentation); they are not a backstop for metadata access. Keep egress rules for every other destination, and rely on IMDSv2, the hop limit, and disabling the metadata endpoint where it is unused to control reachability of the metadata service itself. - **Kubernetes NetworkPolicy egress**: a pod is unrestricted for egress until a `NetworkPolicy` with `Egress` in its `policyTypes` selects it, after which only listed destinations are reachable. This has no effect unless the cluster's network plugin (CNI) implements `NetworkPolicy`; confirm enforcement before relying on it. ```yaml apiVersion: networking.k8s.io/v1 kind: NetworkPolicy metadata: name: agent-egress spec: podSelector: {matchLabels: {app: agent}} policyTypes: [Egress] egress: - to: [{ipBlock: {cidr: 203.0.113.0/24}}] ports: [{protocol: TCP, port: 443}] ``` - **Docker network isolation**: an internal network has no default route out, and Docker's own firewall rules drop traffic leaving it, while containers on the network still reach each other: `docker network create --internal agent-net`. ## Scope boundary Steering a fetcher into requesting the metadata address or an internal host is server-side request forgery, an application-security defect belonging to the code doing the fetching, not to this guide. IMDSv2, the header requirements above, and egress rules are deployment-side controls: they do not prevent the request from being attempted, they make the attempt fail. ## Verify ```bash # AWS, no token supplied: with --http-tokens required this returns 401 curl -s -o /dev/null -w '%{http_code}\n' http://169.254.169.254/latest/meta-data/ # GCP, no Metadata-Flavor header: must fail, must not return metadata curl -s -o /dev/null -w '%{http_code}\n' http://169.254.169.254/computeMetadata/v1/instance/ # Azure, no Metadata: true header: must fail, must not return metadata curl -s -o /dev/null -w '%{http_code}\n' 'http://169.254.169.254/metadata/instance?api-version=2025-04-07' # positive control: a host on the egress allow list, for example the AWS STS endpoint used for role # credentials, must succeed curl -s -o /dev/null -w '%{http_code}\n' --max-time 5 https://sts.amazonaws.com/ # negative control: a known-live host outside the egress allow list, judged by curl's exit status, # not by matching text in its output, since a successful connection also lacks the string # "Could not resolve host" and so would otherwise be misreported as blocked curl -s --max-time 5 -o /dev/null https://example.com/ rc=$? if [ "$rc" -eq 0 ]; then echo "FAIL: connected to a host outside the allow list, egress is not enforced" elif [ "$rc" -eq 6 ]; then echo "inconclusive: DNS resolution failed (curl exit 6), confirm this host still resolves before retrying" elif [ "$rc" -eq 7 ] || [ "$rc" -eq 28 ]; then echo "pass: connection refused or timed out (curl exit $rc), the egress policy is blocking this host" else echo "unexpected curl exit code $rc, investigate before treating this as a pass" fi # confirm the metadata options actually took effect aws ec2 describe-instances --instance-ids i-0123456789abcdef0 \ --query 'Reservations[].Instances[].MetadataOptions' # expect HttpTokens: required, HttpPutResponseHopLimit: 1 ``` ## Sources (checked September 2026) - AWS EC2 instance metadata service configuration: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/configuring-instance-metadata-service.html - AWS VPC security groups (traffic security groups do not filter, including instance metadata): https://docs.aws.amazon.com/vpc/latest/userguide/vpc-security-groups.html - AWS CLI `modify-instance-metadata-options`: https://docs.aws.amazon.com/cli/latest/reference/ec2/modify-instance-metadata-options.html - GCP metadata server overview: https://cloud.google.com/compute/docs/metadata/overview - Azure Instance Metadata Service: https://learn.microsoft.com/en-us/azure/virtual-machines/instance-metadata-service - Kubernetes NetworkPolicy: https://kubernetes.io/docs/concepts/services-networking/network-policies/ - Docker network create (`--internal`): https://docs.docker.com/reference/cli/docker/network/create/ ====================================================================== ==> gpu-clouds.md ====================================================================== # Rented GPUs: RunPod, Vast.ai, Lambda, Modal Unlike the major clouds, most rented-GPU platforms hand you a box (or a serverless function) without a default-deny firewall in front of it. Whatever port a template or container binds, SSH, a Jupyter notebook, a model server, is reachable the moment it listens, and serverless GPU platforms differ in what counts as public by default. Treat every listener the same way you would on your own hardware: bind it privately or authenticate it, per [ollama.md](ollama.md) and [model-servers.md](model-servers.md). ## RunPod Exposed HTTP ports (the "Expose HTTP Ports" pod setting) go through RunPod's proxy at `https://[POD_ID]-[INTERNAL_PORT].proxy.runpod.net`; the proxy terminates HTTPS automatically, "All connections are secured with HTTPS, even if your internal service uses HTTP," but the resulting URL is publicly reachable by anyone who has it, so a bare Jupyter server or model API on an exposed HTTP port still needs its own authentication. Exposed TCP ports get "direct TCP forwarding with a public IP address" instead, and TLS is not automatic there: RunPod's own docs say to "implement TLS in your application when handling sensitive data over TCP." Secure every template listener, including notebooks, before exposing its port; a notebook exposed as raw TCP with no application-level password is fully open. A "symmetrical port mapping" option lets a template request a specific external port above 70000 so the internal and external port numbers match, with the assignment readable from the pod's environment (for example `$RUNPOD_TCP_PORT_70000`); that is a convenience for the pod's own scripts, not a security boundary, and the port is still public once mapped. ## Vast.ai Rented instances open ports per launch mode (port 22 for SSH mode, port 22 plus 8080 for Jupyter mode by default); each internal port maps to a random external port on a shared public IP, and once a port is mapped it is reachable from the internet with no firewall step described in Vast.ai's docs. The Instance Portal fronts web apps on the instance with a reverse proxy (Caddy) when the external and internal ports differ, and secures access with a token rather than a login: "a secure token is appended to the link to prevent unauthorised access to your applications," configured through the `PORTAL_CONFIG` environment variable. Do not strip that token off a shared link, and do not assume an app is private just because it sits behind the portal's port remapping. ## Lambda Lambda's Public Cloud firewall is deny-by-default for inbound traffic with two exceptions: "By default, Lambda allows only incoming ICMP traffic or TCP traffic on port 22 (SSH)." Everything else, a Jupyter notebook, a model server port, needs an explicit firewall rule (global, workspace-wide rules, or a per-instance ruleset attached at launch); Lambda's own guidance is blunt about the tradeoff: "Each port you open increases the attack surface of your instances." Opening a rule makes the port reachable from whatever source range you allow, with no authentication of its own, so pair the rule with the listener's native auth or a proxy in front, not the firewall rule alone. ## Modal Modal's serverless constructs default differently depending on shape. Endpoints and Servers "require authentication by default" and need an explicit `--unauthenticated` flag to go public. Web Functions are the opposite: they are "publicly available by default," and stay that way until the function sets `requires_proxy_auth=True`. Where proxy auth is enabled, callers authenticate with a Token ID and Token Secret pair, either as separate `Modal-Key` and `Modal-Secret` headers or combined as `Authorization: Bearer .` (Modal notes this mirrors "the same scheme the OpenAI API uses"). A protected endpoint called without credentials returns 401 with "missing credentials for proxy authorization." Check which default your construct uses before deploying; do not assume a Web Function is private. ## The pattern, whatever the platform The platform's own controls (RunPod's proxy TLS, Vast.ai's portal token, Lambda's firewall rules, Modal's proxy auth) are necessary but not sufficient on their own. How to bind the model server depends on which kind of proxy is in front of it. RunPod's HTTP proxy reaches the pod over its exposed network interface rather than through a local backend, so the service must bind `0.0.0.0` inside the pod; RunPod's own troubleshooting guidance is explicit that binding to `localhost` only will keep the proxy from reaching it. Vast.ai's Instance Portal is the opposite case: it is a local reverse proxy (Caddy) running on the instance itself, so the app it forwards to can stay on loopback behind it, the same pattern as an authenticating proxy on your own hardware. Whichever binding the platform's proxy needs, require the server's own API key on top as well, exactly as [ollama.md](ollama.md) and [model-servers.md](model-servers.md) describe. A platform-level control that goes away later, a firewall rule deleted, a template rebuilt without a flag, should not be the only thing standing between the listener and the internet. MFA: none of these platforms add a second factor to the workload itself. The account you log into RunPod, Vast.ai, Lambda, or Modal with should have MFA enabled ([mfa.md](mfa.md)); an API key or proxy-auth token secures a machine client, and is a separate control from your platform login. ## Verify ```bash ss -tlnp # enumerate every listening port on the box curl -sS -o /dev/null -w '%{http_code}\n' http://127.0.0.1:8888/api/contents # 403 without the token curl -sS -o /dev/null -w '%{http_code}\n' "https://REPLACE_WITH_POD_ID-REPLACE_WITH_PORT.proxy.runpod.net/REPLACE_WITH_PROTECTED_PATH" # 401 without credentials, against an actual protected endpoint rather than "/" (RunPod's proxy hostname form; substitute the equivalent for your platform) curl -sS -o /dev/null -w '%{http_code}\n' -H "Authorization: Bearer REPLACE_WITH_TOKEN" "https://REPLACE_WITH_POD_ID-REPLACE_WITH_PORT.proxy.runpod.net/REPLACE_WITH_PROTECTED_PATH" # 200 with the correct credentials, so the pair shows the listener itself gates access, not only the proxy hostname's obscurity ``` Every port `ss` shows listening should be either closed (not exposed at the platform layer) or authenticated (the listener itself demands a key, token, or login). A Jupyter server that answers without its token is a finding, whichever of these platforms it runs on. ## Common mistakes - Assuming a rented box has a default-deny firewall like a major cloud's security group; most rented-GPU platforms do not. - Treating a template's HTTPS proxy URL as proof the underlying service is authenticated; the proxy secures transport, not access. - Leaving a Modal Web Function without `requires_proxy_auth=True` because Endpoints and Servers are private by default and it is easy to assume Web Functions are too. - Reusing a rented box's platform login (RunPod/Vast.ai/Lambda/Modal account) as if it were the same thing as the workload's own authentication; they are separate controls. ## Sources (checked September 2026) - RunPod expose ports (proxy HTTPS, TCP forwarding): https://docs.runpod.io/pods/configuration/expose-ports - Vast.ai networking and ports (default ports, port mapping): https://docs.vast.ai/documentation/instances/connect/networking - Vast.ai Instance Portal (PORTAL_CONFIG, Caddy reverse proxy, secure-token links): https://docs.vast.ai/instance-portal - Lambda Cloud firewalls (default-deny inbound, SSH/ICMP exception, rule types): https://docs.lambda.ai/public-cloud/firewalls/ - Modal proxy auth for web endpoints (Endpoints/Servers vs Web Functions defaults, headers): https://modal.com/docs/guide/webhook-proxy-auth ====================================================================== ==> deployment-lifecycle.md ====================================================================== # Deploying safely over time: verify from outside, safe order, previews, teardown This repository's per-service guides check TLS, binding, and authentication from inside the host and at the moment you set them up. Real exposure is judged from outside the host, and it drifts: a preview URL, a restored backup, or an upgrade can reopen something already closed. This guide is the lifecycle layer around the per-service checks. ## 1. Verify from outside, not just from the host A `curl` or `ss -tlnp` run on the host itself cannot see a firewall the host does not know about, or a platform-level bypass like Docker's iptables rules ([docker.md](docker.md)). Verify from a second network: a phone hotspot, a cloud shell, or a second VM. - Port-by-port: `for p in 22 80 443 3000 5432 6379 27017; do nc -vz -w 3 203.0.113.10 "$p"; done`, or a full sweep, `nmap -p- 203.0.113.10` (per the nmap reference, which documents `-p-` and `-p 1-65535` as equivalent port-range syntax; only scan hosts you own or are authorized to test). - Check every address the service actually has, not just the one you remember configuring: the public IPv4 address, the public IPv6 address if the host has one, and any platform-assigned URL alongside your custom domain (a PaaS default subdomain, per [paas.md](paas.md), often stays reachable even when the custom domain is fronted). A scan of one address that misses the others is not a clean result, it is an incomplete one. - Cross-reference what is already indexed about your IP with a passive internet-wide scanner such as Shodan or Censys; both build a continuously updated index of internet-connected hosts and services, so a stale exposure can show up there before you find it yourself ([cloud-firewalls.md](cloud-firewalls.md) covers the firewall rules this is checking). ## 2. Safe initialization order Sequence a first deployment so nothing is reachable before it is safe to reach: 1. Create the owner or admin account's credentials and any signing secrets (session, JWT, or cookie-signing keys) per [authentication.md](authentication.md). 2. Set the registration policy (disable open self-registration, or restrict it to an allowlisted domain) before the service is reachable. 3. Put the fronting gate in place, TLS and any proxy-level authentication, per [free-certificates.md](free-certificates.md) and the fronting-layer guides. 4. Only then open network access. The common failure this order prevents: an unclaimed setup wizard is a race to become admin, open to whoever reaches it first. After that, confirm the setup route itself is closed: restart the service and try hitting the initial-setup URL again; it must refuse to mint a second admin, not silently succeed. ## 3. Previews and clones get production posture A preview deployment or a database clone is not lower stakes just because it is temporary. Vercel's Deployment Protection illustrates the gap: Standard Protection, available on every plan, gates preview and generated deployment URLs but leaves the production domain open by default; the All Deployments scope closes that gap too, and Vercel's September 9, 2026 change made pairing it with Vercel Authentication free on every plan rather than Pro and Enterprise only ([paas.md](paas.md); per Vercel's Deployment Protection changelog, at the time of writing; the configuration reference page itself still listed All Deployments as Pro and Enterprise only when checked, so confirm current availability in your own dashboard). Where the platform's own gate does not cover a hostname, front it the same way as production, for example a Cloudflare Access policy scoped to that preview hostname. Either way, treat a clone's data the same as the original: rotate any credential a clone inherited if the clone is less trusted than the source. ## 4. Teardown: DNS before the app, then revoke the rest Retiring a deployment in the wrong order leaves a dangling DNS record pointing at a resource someone else can now claim (a subdomain takeover). Delete the DNS record (the `CNAME` or `A`/`AAAA`) before you delete or release the underlying app, load balancer, or IP. Then revoke what pointed at it: access policies (Cloudflare Access or equivalent), API tokens and service credentials scoped to that deployment ([machine-auth.md](machine-auth.md)), and database users created only for it. ## 5. Re-verify after anything changes Rerun the outside-in checks in section 1 and the negative auth tests from [authentication.md](authentication.md) after an upgrade, a backup restore (which can reintroduce a default account or reset a feature flag), or any change to network policy or auth configuration. Monitor certificate expiry from outside the host too: a renewal cron can silently fail while the host's own view still looks fine, so an external check (a scheduled `openssl s_client` from another machine, or a third-party uptime/certificate monitor) catches what a local `certbot renew --dry-run` cannot. ## 6. Offboarding When a person leaves, revoke access at every layer they touched, not only their password: identity-provider membership (the SSO or directory account), proxy-level sessions (Cloudflare Access or equivalent), application-level sessions on each service, and any personal access tokens or API keys issued to them. Where a system cannot revoke immediately, such as a session cached until its own expiry, know that system's maximum time-to-revoke and treat the number as something to shrink, not accept. [mfa.md](mfa.md) covers revoking the second factor alongside the account. ## Common mistakes - Verifying only the custom domain and forgetting the platform's own generated URL, which is often still reachable and unauthenticated. - Deleting the app first and the DNS record later, leaving exactly the dangling-CNAME window a takeover needs. - Trusting a local `certbot renew --dry-run` as proof that production certificates are actually renewing; it proves the client works, not that the last real renewal succeeded. ## Verify ```bash # From a second network, not the host itself: nmap -p- 203.0.113.10 # only the intended ports answer nmap -6 -p- 2001:db8::10 # same, over the public IPv6 address curl -sI https://retired-preview.example.com/ # expect DNS failure or connection error dig +short retired-preview.example.com # expect no record, not a dangling CNAME openssl s_client -connect app.example.com:443 -servername app.example.com postgresql.md ====================================================================== # PostgreSQL: TLS and authentication Default posture: PostgreSQL should not listen on public interfaces at all. Widen `listen_addresses` only for genuine remote clients, and then require both TLS and SCRAM authentication as below. ## 1. Server TLS Get a certificate ([free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md)), give the key to the `postgres` user with mode `600`, then in `postgresql.conf`: ``` listen_addresses = 'localhost' # widen deliberately, e.g. 'localhost,10.0.0.5' ssl = on ssl_cert_file = '/etc/ssl/certs/server.crt' ssl_key_file = '/etc/ssl/private/server.key' ssl_min_protocol_version = 'TLSv1.2' # PostgreSQL 12 and later password_encryption = scram-sha-256 # default from PostgreSQL 14; set explicitly on older versions ``` The `ssl*` and `password_encryption` settings apply on reload (`SELECT pg_reload_conf();` or `systemctl reload postgresql`); `listen_addresses` can only be set at server start, so a change to it needs `systemctl restart postgresql`, then `ss -tlnp | grep 5432` to confirm the bind. ## 2. Require TLS per connection in pg_hba.conf `hostssl` matches only TLS connections; plain `host` lines accept cleartext. Remote entries should all be `hostssl` with `scram-sha-256`: ``` # TYPE DATABASE USER ADDRESS METHOD local all all peer hostssl app app 10.0.0.0/24 scram-sha-256 # No 'host ... 0.0.0.0/0 trust' or 'password' lines. Ever. ``` Passwords set before `password_encryption = scram-sha-256` remain MD5-hashed; re-set them (`\password app`) so SCRAM applies. For machine-to-machine links, add certificate verification on top of SCRAM: set `ssl_ca_file` in `postgresql.conf` and append `clientcert=verify-full` to the `hostssl` line (PostgreSQL 12 and later). MFA: the PostgreSQL wire protocol has no TOTP dialogue. For direct connections `clientcert=verify-full` adds a possession factor held by the connecting machine, stronger than a password alone but not MFA for a person; chain the `radius` authentication method to an MFA service (for example the Duo Authentication Proxy) where policy requires it, and put the human paths to the host (SSH, admin UIs) behind MFA per [mfa.md](mfa.md). ## 3. Client side Require identity verification in the connection settings, in addition to encryption: ``` psql "host=db.example.com dbname=app user=app sslmode=verify-full sslrootcert=/path/ca.crt" ``` `sslmode=require` encrypts but does not verify the server's identity; `verify-full` does both. Application connection strings take the same parameters. ## 4. Verify ```bash psql -h db.example.com -U app -c "SELECT version();" \ "dbname=app sslmode=verify-full sslrootcert=/path/ca.crt" sudo -u postgres psql -c "SELECT ssl, count(*) FROM pg_stat_ssl JOIN pg_stat_activity USING (pid) GROUP BY ssl;" ss -tlnp | grep 5432 # loopback only, unless remote access is deliberate ``` A connection attempt without TLS from a remote host must fail once only `hostssl` lines cover remote addresses. ## Common mistakes - `listen_addresses = '*'` plus a permissive `host all all 0.0.0.0/0 md5` line pasted from a tutorial. - `trust` authentication left enabled for remote addresses. - The superuser (`postgres`) used as the application account; create a least-privilege role instead ([authentication.md](authentication.md)). ## Sources (checked September 2026) - Secure TCP/IP connections with SSL: https://www.postgresql.org/docs/current/ssl-tcp.html - pg_hba.conf: https://www.postgresql.org/docs/current/auth-pg-hba-conf.html - libpq SSL support (sslmode): https://www.postgresql.org/docs/current/libpq-ssl.html - Connections and authentication (`listen_addresses` "can only be set at server start"): https://www.postgresql.org/docs/current/runtime-config-connection.html ====================================================================== ==> mysql.md ====================================================================== # MySQL and MariaDB: TLS and authentication Default posture: keep the server on `127.0.0.1` (the packaged default on Debian/Ubuntu) and open it to remote clients only deliberately, with TLS required. ## 1. Server TLS MySQL 8 generates a CA and server certificate in the data directory at initialization and enables TLS automatically; check with: ```sql SHOW GLOBAL VARIABLES LIKE '%ssl%'; ``` To use your own certificate ([free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md)) and to refuse all cleartext connections, set in `/etc/mysql/mysql.conf.d/mysqld.cnf` (or the equivalent for your packaging): ```ini [mysqld] bind_address = 127.0.0.1 # widen deliberately require_secure_transport = ON tls_version = TLSv1.2,TLSv1.3 ssl_ca = /etc/mysql/certs/ca.pem ssl_cert = /etc/mysql/certs/server-cert.pem ssl_key = /etc/mysql/certs/server-key.pem ``` `require_secure_transport` rejects any TCP connection that is not TLS (Unix-socket connections remain allowed). Recent MariaDB versions support the same option; verify availability for your release. ## 2. Per-account requirements Require TLS (or a client certificate) at the account level as a second control: ```sql ALTER USER 'app'@'10.0.0.%' REQUIRE SSL; -- or, for mutual TLS: ALTER USER 'batch'@'10.0.0.%' REQUIRE X509; ``` Account hygiene per [authentication.md](authentication.md): keep the default `caching_sha2_password` plugin for new accounts (MySQL 8) rather than re-enabling `mysql_native_password`, remove anonymous accounts, and give the application a least-privilege user, never `root`. MFA: MySQL 8.0.27 and later support up to 3 authentication factors per account, with factors 2 and 3 supplied by external plugins. The device plugin is FIDO from 8.0.27 (deprecated as of 8.0.35, removed in 8.4) and WebAuthn, which replaces it, from 8.2 onward including 8.4 LTS; in both cases the server-side plugin ships only in Enterprise Edition. On Community builds, `REQUIRE X509` client certificates add a possession factor held by the connecting machine, stronger than a password alone but not MFA for a person; put human access paths behind MFA per [mfa.md](mfa.md). ## 3. Client side Require identity verification of the server in addition to encryption: ```bash mysql --host db.example.com --user app -p \ --ssl-mode=VERIFY_IDENTITY --ssl-ca=/path/ca.pem ``` `--ssl-mode=REQUIRED` encrypts without identity verification; `VERIFY_CA`/`VERIFY_IDENTITY` verify the certificate (MySQL clients; MariaDB clients use `--ssl-verify-server-cert`). Connector options in application code follow the same distinction. ## 4. Verify ```sql SHOW GLOBAL VARIABLES LIKE 'require_secure_transport'; SELECT user, host, ssl_type FROM mysql.user; -- REQUIRE settings per account \s -- in the client: the SSL line shows the cipher ``` ```bash ss -tlnp | grep 3306 # loopback only, unless remote access is deliberate ``` ## Common mistakes - Creating `'app'@'%'` with a weak password to fix a connection error, then never tightening the host mask. - `require_secure_transport = ON` skipped because "the network is internal"; internal networks are where lateral movement happens. - Shipping the client with `--ssl-mode=DISABLED` to silence certificate errors instead of installing the CA ([self-signed.md](self-signed.md)). ## Sources (checked September 2026) - MySQL encrypted connections: https://dev.mysql.com/doc/refman/8.0/en/using-encrypted-connections.html - MySQL multifactor authentication: https://dev.mysql.com/doc/refman/8.0/en/multifactor-authentication.html - MariaDB TLS documentation: https://mariadb.com/kb/en/secure-connections-overview/ - WebAuthn pluggable authentication (MySQL 8.4): https://dev.mysql.com/doc/refman/8.4/en/webauthn-pluggable-authentication.html - FIDO pluggable authentication (MySQL 8.0, deprecated as of 8.0.35): https://dev.mysql.com/doc/refman/8.0/en/fido-pluggable-authentication.html - What is new in MySQL 8.4 (`authentication_fido` plugins removed): https://dev.mysql.com/doc/refman/8.4/en/mysql-nutshell.html ====================================================================== ==> mongodb.md ====================================================================== # MongoDB: TLS and authorization MongoDB's history of mass data leaks comes from 2 settings: binding to all interfaces and running with authorization off. Fix both before anything else, then add TLS. Applies to MongoDB 4.2 and later (`tls` options; earlier versions used `ssl` names). ## 1. Enable authorization and create the admin user In `/etc/mongod.conf`: ```yaml net: port: 27017 bindIp: 127.0.0.1 # widen deliberately, e.g. 127.0.0.1,10.0.0.5 security: authorization: enabled ``` Restart, then use the localhost exception to create the first administrator (connect from the server itself with `mongosh`): ```javascript use admin db.createUser({ user: "admin", pwd: passwordPrompt(), roles: [ { role: "userAdminAnyDatabase", db: "admin" } ] }) ``` Create a separate least-privilege user per application (for example `readWrite` on its own database), per [authentication.md](authentication.md). Modern MongoDB authenticates with SCRAM-SHA-256 by default. MFA: the wire protocol has no TOTP dialogue in Community edition; x.509 client-certificate authentication adds a possession factor held by the connecting machine for direct connections, stronger than a password alone but not MFA for a person, and human paths to the host (SSH, admin UIs) go behind MFA per [mfa.md](mfa.md). ## 2. Enable TLS Get a certificate ([free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md)), concatenate certificate and key into one PEM, and require TLS: ```bash cat server.crt server.key > /etc/ssl/mongodb/server.pem chmod 600 /etc/ssl/mongodb/server.pem ``` ```yaml net: tls: mode: requireTLS certificateKeyFile: /etc/ssl/mongodb/server.pem # validates any certificate a client or cluster member presents CAFile: /etc/ssl/mongodb/ca.crt # clients authenticate with SCRAM over TLS; set false when every client holds a certificate allowConnectionsWithoutCertificates: true ``` With `CAFile` set, `mongod` expects every client to present a certificate unless `allowConnectionsWithoutCertificates: true`; the setting still validates any certificate a client does present, and it is what lets the SCRAM-only `mongosh` connections below work. `requireTLS` rejects plain connections outright; the transitional modes (`allowTLS`, `preferTLS`) exist for rolling upgrades only. ## 3. Client side ```bash mongosh "mongodb://admin@db.example.com:27017/?authSource=admin&tls=true" \ --tlsCAFile /path/ca.crt ``` Driver connection strings take the same `tls=true` and CA options. Do not ship `tlsAllowInvalidCertificates`; install the CA instead ([self-signed.md](self-signed.md)). ## 4. Verify ```bash ss -tlnp | grep 27017 # loopback only, unless remote access is deliberate mongosh --host db.example.com # without credentials/TLS: refused once hardened mongosh "mongodb://db.example.com/?tls=true" --tlsCAFile ca.crt # connects, then requires auth ``` From an unauthenticated session, `show dbs` must fail with an authorization error. ## Common mistakes - `bindIp: 0.0.0.0` set to fix a connection problem, with `authorization` still unset; this is the classic leaked-database configuration. - Authorization enabled but every service sharing the `admin` account. - TLS on the server while the connection string still says `tls=false` because a container healthcheck was easier that way. ## Sources (checked September 2026) - MongoDB security checklist: https://www.mongodb.com/docs/manual/administration/security-checklist/ - Configure TLS/SSL for mongod: https://www.mongodb.com/docs/manual/tutorial/configure-ssl/ - Enable access control: https://www.mongodb.com/docs/manual/tutorial/enable-authentication/ - Configuration file options (`net.tls.CAFile`, `net.tls.allowConnectionsWithoutCertificates`): https://www.mongodb.com/docs/manual/reference/configuration-options/ ====================================================================== ==> redis.md ====================================================================== # Redis: TLS and authentication Redis trusts its network by design, so the network boundary and credentials are your job. An exposed unauthenticated Redis leaks its data, and historic attack tooling has also used the CONFIG command against open instances to write files and take over hosts. Applies to Redis 6.0 and later (TLS and ACLs); the server must be built with TLS support, which mainstream distribution packages include (Redis refuses to start with TLS directives present if the build lacks it). ## 1. Keep it local unless remote access is deliberate In `redis.conf`: ``` bind 127.0.0.1 -::1 protected-mode yes ``` `protected-mode` blocks non-loopback clients when no password/ACL is set; treat it as a backstop, not as the control. ## 2. Require a credential Minimum (single shared password, sent by clients with `AUTH`): ``` requirepass REPLACE_WITH_LONG_RANDOM_PASSWORD ``` Better, per-service ACL users with least privilege (Redis 6 and later): ``` user app on >REPLACE_WITH_LONG_RANDOM_PASSWORD ~app:* +@read +@write ``` That grants the `app` user access to keys matching `app:*` with read and write command categories only. Generate passwords per [authentication.md](authentication.md). Disable the `default` user (`user default off`) only after every client authenticates as a named user, or you will lock services out. MFA: Redis has no second-factor dialogue. For machine clients, `tls-auth-clients yes` (mutual TLS, below) adds a possession factor (a certificate) alongside the password; that is stronger than a password alone but it is not MFA for a person. Human paths to the host go behind MFA per [mfa.md](mfa.md). ## 3. Enable TLS Get a certificate ([free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md)), then replace the plaintext port with a TLS listener: ``` # no plaintext listener at all port 0 tls-port 6379 tls-cert-file /etc/redis/tls/server.crt tls-key-file /etc/redis/tls/server.key tls-ca-cert-file /etc/redis/tls/ca.crt # yes = require client certificates (mutual TLS) tls-auth-clients no ``` Set `tls-auth-clients yes` for machine-to-machine deployments where clients can hold certificates; it is stronger than passwords alone. ## 4. Client side ```bash redis-cli --tls --cacert /etc/redis/tls/ca.crt -h redis.example.com -p 6379 > AUTH app REPLACE_WITH_PASSWORD > GET app:probe ``` `GET app:probe` is allowed by `~app:* +@read` (a `(nil)` reply is a success); `PING` sits in the `@connection` and `@fast` categories, which the ACL above does not grant, so it fails for `app`. Application clients take equivalent TLS and credential options; point them at the CA rather than disabling verification. ## 5. Verify ```bash ss -tlnp | grep 6379 # loopback only, unless remote access is deliberate redis-cli -h redis.example.com ping # plaintext attempt: fails once port 0 is set redis-cli --tls --cacert ca.crt -h redis.example.com ping # NOAUTH error until AUTH succeeds ``` ## Common mistakes - Commenting out `bind` (which listens everywhere) while `requirepass` is still empty. - One `requirepass` value shared across environments and committed to the repository. - TLS enabled but the plaintext `port` left open alongside it; set `port 0`. ## Valkey Valkey, the community fork of Redis, uses the same `requirepass`, ACL, and TLS configuration described above without changes; apply this guide's steps directly. See [valkey.io](https://valkey.io/). ## Sources (checked September 2026) - Redis documentation (security, TLS, and ACL pages): https://redis.io/docs/latest/ - redis.conf self-documented example in the Redis source distribution: https://github.com/redis/redis - Redis configuration (directive format `keyword argument1 argument2 ... argumentN`): https://redis.io/docs/latest/operate/oss_and_stack/management/config/ - PING command reference (ACL categories `@fast`, `@connection`): https://redis.io/docs/latest/commands/ping/ - Valkey: https://valkey.io/ ====================================================================== ==> elasticsearch.md ====================================================================== # Elasticsearch and OpenSearch: keep security switched on Open Elasticsearch instances produced some of the largest data leaks on record. Modern versions ship secure; the failure mode today is deliberately switching protection off to make an error message go away. ## Elasticsearch (8.0 and later) - A fresh install auto-configures security on first start: authentication is enabled, TLS is set up for HTTP and transport, and a password is generated for the `elastic` superuser. Keep all of it. - Never set `xpack.security.enabled: false`, and never expose a node where TLS (`xpack.security.http.ssl`) has been turned off. If a client cannot connect, fix the client's CA trust ([self-signed.md](self-signed.md)) or issue a real certificate ([free-certificates.md](free-certificates.md)); do not remove the lock. - Bind stays local unless deliberately widened (`network.host`); remote access goes through the same decision as any database: private network, VPN or tunnel, TLS everywhere. - Create least-privilege users and API keys per application instead of shipping `elastic` credentials ([authentication.md](authentication.md)). ## OpenSearch - The security plugin provides authentication and TLS; never run with it disabled, including in Docker examples. - Recent versions require an initial admin password at install (the `OPENSEARCH_INITIAL_ADMIN_PASSWORD` environment variable for the demo configuration; verify the exact mechanism for your version). Make it long and random. - The demo configuration installs demo TLS certificates for evaluation; replace them with your own before any real deployment. ## Verify ```bash curl -s https://search.example.com:9200/ # 401 without credentials curl -sk https://localhost:9200/ -u elastic # prompts; TLS answers, HTTP does not ss -tlnp | grep 9200 # loopback/private only, unless deliberate ``` An unauthenticated `GET /` returning cluster JSON is the classic finding; so is `_cat/indices` listing your data to the world. ## Sources (checked September 2026) - Elasticsearch security configuration: https://www.elastic.co/guide/en/elasticsearch/reference/current/configuring-stack-security.html - OpenSearch demo security configuration: https://docs.opensearch.org/latest/security/configuration/demo-configuration/ ====================================================================== ==> minio.md ====================================================================== # MinIO: root credentials and TLS MinIO serves S3-compatible object storage; an exposed instance with weak or well-known credentials hands over every bucket. Both the S3 API port and the web console need the same care. Lifecycle note, as of September 2026: the MinIO community repository on GitHub was archived on 2026-04-25 and carries the notice that it is no longer maintained; MinIO now ships AIStor Free (a standalone edition under a free licence) and AIStor Enterprise. The settings below are documented for AIStor. An archived community build receives no security fixes, so treat running one as a finding and plan the migration. ## 1. Set real root credentials ```bash export MINIO_ROOT_USER="REPLACE_WITH_ADMIN_NAME" export MINIO_ROOT_PASSWORD="REPLACE_WITH_LONG_RANDOM_VALUE" ``` Never run with the historic `minioadmin`/`minioadmin` pair; scanners try it constantly. Root credentials are for administration only: create per-application access keys with least-privilege policies (via the console or the `mc` client) so no app holds root ([authentication.md](authentication.md)). ## 2. Enable TLS MinIO serves HTTPS automatically when it finds a PEM key pair named `public.crt` and `private.key` in `${HOME}/.minio/certs` (or the directory given with `--certs-dir`): ```bash cp fullchain.pem ${HOME}/.minio/certs/public.crt cp privkey.pem ${HOME}/.minio/certs/private.key ``` Certificates per [free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md); clients then use `https://` endpoints and, for self-signed, trust the CA rather than disabling verification. ## 3. Exposure posture Loopback or private networks by default; public access only via the TLS endpoints above or behind a proxy/tunnel ([nginx.md](nginx.md), [cloudflare.md](cloudflare.md)). Keep the console off the public internet and give human logins MFA at the fronting layer ([mfa.md](mfa.md)). Buckets are private unless a policy says otherwise; audit anonymous/public bucket policies before exposing anything. ## 4. Verify ```bash ss -tlnp | grep 9000 # private unless deliberate curl -s https://s3.example.com:9000/ # answers over TLS; anonymous access denied mc alias set mys3 https://s3.example.com:9000 REPLACE_WITH_ACCESS_KEY REPLACE_WITH_SECRET_KEY # app key works; root key stays unused by apps ``` ## Sources (checked September 2026) - MinIO network encryption (certs directory, public.crt/private.key, --certs-dir): https://docs.min.io/enterprise/aistor-object-store/installation/linux/network-encryption/ - MinIO: https://min.io/ - MinIO community repository (archived 2026-04-25, successor editions): https://github.com/minio/minio ====================================================================== ==> rabbitmq.md ====================================================================== # RabbitMQ: users, TLS listener, and the guest account RabbitMQ's default `guest`/`guest` account can only connect from localhost, which protects fresh installs exactly until someone "fixes" it. The documented recommendation is to create real users and delete `guest` or change its password. ## 1. Accounts ```bash sudo rabbitmqctl add_user 'app' 'REPLACE_WITH_LONG_RANDOM_PASSWORD' sudo rabbitmqctl set_permissions -p '/' 'app' '.*' '.*' '.*' # configure, write, read; narrow per app sudo rabbitmqctl add_user 'ops' '...' sudo rabbitmqctl set_user_tags 'ops' administrator sudo rabbitmqctl delete_user 'guest' ``` Scope the permission regexes to what each application actually uses, per [authentication.md](authentication.md). Do not loosen the guest account's localhost restriction. ## 2. TLS listener `rabbitmq.conf`: ``` listeners.ssl.default = 5671 ssl_options.cacertfile = /etc/rabbitmq/tls/ca.pem ssl_options.certfile = /etc/rabbitmq/tls/server.pem ssl_options.keyfile = /etc/rabbitmq/tls/server.key ssl_options.verify = verify_peer # mutual TLS; set false to allow password-only clients ssl_options.fail_if_no_peer_cert = true # once every client speaks TLS: listeners.tcp = none ``` Certificates per [self-signed.md](self-signed.md) (internal CA fits brokers well) or [free-certificates.md](free-certificates.md). Mutual TLS gives a machine client a possession factor, a certificate held by the connecting host, stronger than a password alone but not MFA for a person ([mfa.md](mfa.md)). ## 3. Management UI The management plugin's web UI is an admin panel: keep it off public interfaces and reach it per [admin-uis.md](admin-uis.md) (SSH forward, tailnet, or Access), with its own TLS when remote. ## 4. Verify ```bash ss -tlnp | grep -E '5671|5672|15672' # 5672 gone once listeners.tcp = none; UI private openssl s_client -connect mq.example.com:5671 -CAfile ca.pem mosquitto.md ====================================================================== # Mosquitto (MQTT): no anonymous clients, TLS listener MQTT brokers back IoT and agent projects, and open brokers leak live telemetry and accept injected commands. Mosquitto's defaults are sane (with listeners defined, anonymous access is off; without any listener it serves the local machine only); the job is to keep them sane while adding real listeners. ## 1. Credentials per device ```bash sudo mosquitto_passwd -c /etc/mosquitto/passwd device-01 # -c only the first time sudo mosquitto_passwd /etc/mosquitto/passwd device-02 ``` `/etc/mosquitto/conf.d/secure.conf`: ``` per_listener_settings false allow_anonymous false password_file /etc/mosquitto/passwd ``` One credential per device, so a leaked unit can be revoked alone; add an `acl_file` to limit each identity to its own topics. ## 2. TLS listener ``` listener 8883 cafile /etc/mosquitto/tls/ca.pem certfile /etc/mosquitto/tls/server.pem keyfile /etc/mosquitto/tls/server.key # mutual TLS: clients must present certificates # require_certificate true ``` Port 8883 is the conventional MQTT-over-TLS port. Certificates per [self-signed.md](self-signed.md) (an internal CA suits device fleets) or [free-certificates.md](free-certificates.md). `require_certificate true` makes a client certificate a possession factor for the connecting device, stronger than a password alone but not MFA for a person ([mfa.md](mfa.md)). Remove or firewall any plaintext `listener 1883` that is not strictly local. ## 3. Verify ```bash mosquitto_sub -h mq.example.com -p 8883 --cafile ca.pem -t 'test' -u device-01 -P '...' # works mosquitto_sub -h mq.example.com -p 8883 --cafile ca.pem -t 'test' # refused (no credentials) ss -tlnp | grep -E '1883|8883' # no public 1883 ``` ## Sources (checked September 2026) - mosquitto.conf manual (allow_anonymous defaults, password_file, listener, certfile/keyfile/cafile, require_certificate): https://mosquitto.org/man/mosquitto-conf-5.html ====================================================================== ==> kafka.md ====================================================================== # Apache Kafka: SASL_SSL listeners, SCRAM credentials, and ACLs Kafka's broker defaults are `listeners=PLAINTEXT://:9092`, `security.inter.broker.protocol=PLAINTEXT`, and no authorizer, so anyone who reaches port 9092 can read every topic, produce to it, and create or delete topics with no credential and no encryption. Property names below come from the Kafka 4.x documentation (KRaft mode). ## 1. Replace the plaintext listener In `server.properties`, publish one `SASL_SSL` listener and use it between brokers too. Remove `PLAINTEXT://:9092`; if local tooling still needs it, bind it to `127.0.0.1` and never advertise it. KRaft controllers use their own listener (`controller.listener.names`); map it to `SASL_SSL` in `listener.security.protocol.map` (the documentation's example is `BROKER:SASL_SSL,CONTROLLER:SASL_SSL`) or keep it on a private interface. ```properties listeners=SASL_SSL://0.0.0.0:9093 advertised.listeners=SASL_SSL://kafka.example.com:9093 security.inter.broker.protocol=SASL_SSL ``` ## 2. TLS on the broker Get a certificate ([free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md); an internal CA fits a cluster) and point the broker at it. Kafka 2.7.0 and later also take PEM: `ssl.keystore.type=PEM` with `ssl.keystore.certificate.chain` and `ssl.keystore.key` (PKCS#8), and `ssl.truststore.type=PEM` with `ssl.truststore.certificates`. Hostname verification (`ssl.endpoint.identification.algorithm`) is on by default since 2.0.0; the documentation discourages blanking it. ```properties ssl.keystore.location=/var/private/ssl/server.keystore.jks ssl.keystore.password=REPLACE_WITH_LONG_RANDOM_VALUE ssl.key.password=REPLACE_WITH_LONG_RANDOM_VALUE ssl.truststore.location=/var/private/ssl/server.truststore.jks ssl.truststore.password=REPLACE_WITH_LONG_RANDOM_VALUE # Client certificates on the SASL_SSL listener: none (default), requested (optional), or required (mutual TLS). # The unprefixed ssl.client.auth applies only to SSL listeners, so a SASL_SSL listener needs the listener prefix. listener.name.sasl_ssl.ssl.client.auth=none ``` ## 3. SASL/SCRAM credentials The documentation says SCRAM should be used only with TLS, hence `SASL_SSL` rather than `SASL_PLAINTEXT`. In KRaft the inter-broker credential must exist before the brokers first start, so create it while formatting storage. Once the cluster is up, give each application its own credential ([authentication.md](authentication.md)) with `kafka-configs.sh`, authenticating as the admin through a properties file like the one in step 5. ```bash bin/kafka-storage.sh format -t $(bin/kafka-storage.sh random-uuid) -c config/server.properties \ --add-scram 'SCRAM-SHA-512=[name="admin",password="REPLACE_WITH_LONG_RANDOM_VALUE"]' # after the brokers are running: bin/kafka-configs.sh --bootstrap-server kafka.example.com:9093 --command-config admin.properties \ --alter --add-config 'SCRAM-SHA-512=[password=REPLACE_WITH_LONG_RANDOM_VALUE]' \ --entity-type users --entity-name app ``` ```properties sasl.enabled.mechanisms=SCRAM-SHA-512 sasl.mechanism.inter.broker.protocol=SCRAM-SHA-512 listener.name.sasl_ssl.scram-sha-512.sasl.jaas.config=org.apache.kafka.common.security.scram.ScramLoginModule required username="admin" password="REPLACE_WITH_LONG_RANDOM_VALUE"; ``` ## 4. Authorization Without an authorizer every authenticated user can do everything. Enable the KRaft authorizer on every node, keep deny-by-default, and name only the admin as a super user; then grant each principal what it uses (`--producer` and `--consumer` add the matching operation sets). ```properties authorizer.class.name=org.apache.kafka.metadata.authorizer.StandardAuthorizer allow.everyone.if.no.acl.found=false super.users=User:admin ``` ```bash bin/kafka-acls.sh --bootstrap-server kafka.example.com:9093 --command-config admin.properties \ --add --allow-principal User:app --producer --topic orders bin/kafka-acls.sh --bootstrap-server kafka.example.com:9093 --command-config admin.properties \ --add --allow-principal User:app --consumer --topic orders --group app-workers ``` ## 5. Client side `client.properties`, kept out of the repository ([secrets.md](secrets.md)). MFA: the Kafka protocol has no second-factor dialogue; `listener.name.sasl_ssl.ssl.client.auth=required` (mutual TLS; the unprefixed `ssl.client.auth` applies only to `SSL` listeners, and Kafka logs a warning when it is set without the prefix on a `SASL_SSL` broker) is the possession factor for machine clients ([machine-auth.md](machine-auth.md)), and each client then presents its own keystore as in the last three lines below; human paths to the brokers or a management UI go behind MFA per [mfa.md](mfa.md). ```properties security.protocol=SASL_SSL sasl.mechanism=SCRAM-SHA-512 sasl.jaas.config=org.apache.kafka.common.security.scram.ScramLoginModule required username="app" password="REPLACE_WITH_LONG_RANDOM_VALUE"; ssl.truststore.location=/var/private/ssl/client.truststore.jks ssl.truststore.password=REPLACE_WITH_LONG_RANDOM_VALUE # only when the listener requires client certificates ssl.keystore.location=/var/private/ssl/client.keystore.jks ssl.keystore.password=REPLACE_WITH_LONG_RANDOM_VALUE ssl.key.password=REPLACE_WITH_LONG_RANDOM_VALUE ``` ## 6. Redpanda (and other Kafka-API-compatible systems) Redpanda implements the Kafka wire protocol, so the client-side and protocol-level controls above carry over: SASL/SCRAM authentication, TLS, and ACL management through the Kafka API all work the same way against a Redpanda cluster, and the same fronting rules apply too. What does not carry over is how you wire that up on the broker: Redpanda configures brokers through `redpanda.yaml` and the `rpk` CLI, not Kafka's `server.properties`, JAAS configuration files, or `kafka-storage.sh`. Redpanda Console, its bundled web UI, ships without its own login screen: it only gains one once you configure OIDC or basic authentication, so until then anyone who reaches Console reaches the cluster behind it. Front Console the same way as any other admin panel: never public, reached through SSH forwarding, a tailnet, or an access proxy, with MFA at that layer. See the [Redpanda security documentation](https://docs.redpanda.com/current/manage/security/). ## Verify ```bash ss -tlnp | grep -E '9092|9093' # 9093 only, or 9092 on 127.0.0.1 openssl s_client -connect kafka.example.com:9093 clickhouse.md ====================================================================== # ClickHouse: listen address, the default user, and TLS ports ClickHouse listens on localhost only until you set `listen_host`, but it ships with a `default` user that has an empty password, may connect from any address (`::/0`), and holds `access_management`, so widening `listen_host` to `::` or `0.0.0.0` publishes a passwordless administrator on plaintext HTTP 8123 and native TCP 9000. Widen only after steps 2 and 3. ## 1. Keep `listen_host` narrow The shipped `config.xml` comments out every `listen_host` example and says the default is to "try listen localhost on IPv4 and IPv6". For remote clients, name the one private address rather than `::`. Every port then binds there: `http_port` 8123, `tcp_port` 9000, `mysql_port` 9004, `postgresql_port` 9005, and `interserver_http_port` 9009 (replica traffic). Firewall them per [cloud-firewalls.md](cloud-firewalls.md) or [host.md](host.md). ```xml 127.0.0.1 203.0.113.10 ``` ## 2. Put a password on `default`, and create real users In `users.xml` the `default` user is ``. Replace that with a hash (the shipped file documents `echo -n "$PASSWORD" | sha256sum | tr -d '-'`) and restrict where it may connect from. `password_double_sha1_hex` exists for MySQL-protocol clients; plain `` is documented but stores the secret in clear. ```xml REPLACE_WITH_THE_SHA256_HEX_OF_A_LONG_RANDOM_PASSWORD ::1 127.0.0.1 default default 1 ``` Because `default` holds `access_management`, use it once to create per-application users with SQL, each limited to its source network and its database ([authentication.md](authentication.md)). `IDENTIFIED WITH bcrypt_password BY '...'` (72-character maximum) is also available and stores a slower hash. The access-control documentation recommends disabling `default` in production once a SQL admin user exists and inter-node credentials are configured, since `default` is what nodes use to talk to each other; until then, the loopback-only `` above keeps it off the network. ```sql CREATE USER app HOST IP '10.0.0.0/8' IDENTIFIED WITH sha256_password BY 'REPLACE_WITH_LONG_RANDOM_VALUE'; GRANT SELECT, INSERT ON appdb.* TO app; ``` ## 3. TLS listeners, plaintext ports off Get a certificate ([free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md)), enable the secure ports, and comment out the plaintext ones, as the vendor's TLS guide does. Treat `mysql_port`, `postgresql_port`, and `interserver_http_port` the same way: remove them or keep them on a private address (`interserver_https_port` 9010 is the TLS variant). Users can also be identified by client certificate (`IDENTIFIED WITH ssl_certificate CN 'name'`), the possession factor for machine clients ([machine-auth.md](machine-auth.md)). MFA: ClickHouse has no second-factor dialogue of its own; human paths to the host and to any dashboard in front of it go behind MFA per [mfa.md](mfa.md). ```xml 8443 9440 /etc/clickhouse-server/certs/server.crt /etc/clickhouse-server/certs/server.key sslv2,sslv3 true ``` ## Verify Clients use `clickhouse-client --secure` on 9440, or HTTPS on 8443 with HTTP basic auth or the `X-ClickHouse-User` and `X-ClickHouse-Key` headers; the documentation discourages `user` and `password` URL parameters because proxies log them. ```bash ss -tlnp | grep -E '8123|9000|8443|9440' # only 8443 and 9440, on the intended address curl -s http://ch.example.com:8123/ # connection refused curl -s 'https://ch.example.com:8443/?query=SELECT%201' # no credentials = default with empty password: authentication error curl -u app:REPLACE_WITH_LONG_RANDOM_VALUE 'https://ch.example.com:8443/?query=SELECT%201' # 1 clickhouse-client --host ch.example.com --port 9440 --secure --user app --password ``` ## Common mistakes - `::` uncommented to reach the server from a laptop, with `default` still passwordless. - A password set on `default` while `` still says `::/0`, so the one administrator account is guessable from anywhere. - `https_port` added while `http_port` 8123 stays open beside it. ## Sources (checked September 2026) - Server configuration parameters (ports, `listen_host`, `openSSL`): https://clickhouse.com/docs/operations/server-configuration-parameters/settings - Shipped `config.xml` (`listen_host` default comment, `openSSL` block): https://raw.githubusercontent.com/ClickHouse/ClickHouse/master/programs/server/config.xml - User settings (`password_sha256_hex`, `networks`, `access_management`): https://clickhouse.com/docs/operations/settings/settings-users - Shipped `users.xml` (default user, empty password, `::/0`): https://raw.githubusercontent.com/ClickHouse/ClickHouse/master/programs/server/users.xml - Access control and account management: https://clickhouse.com/docs/operations/access-rights - CREATE USER: https://clickhouse.com/docs/sql-reference/statements/create/user - GRANT: https://clickhouse.com/docs/sql-reference/statements/grant - Configuring SSL-TLS: https://clickhouse.com/docs/guides/sre/configuring-ssl - HTTP interface (ports, authentication): https://clickhouse.com/docs/interfaces/http ====================================================================== ==> neo4j.md ====================================================================== # Neo4j: listen address, initial password, and TLS on Bolt and HTTPS Neo4j 5 listens on `localhost` only by default and ships with authentication on, but with the well-known `neo4j`/`neo4j` credential, Bolt TLS at `DISABLED`, and plaintext HTTP enabled instead of HTTPS. Setting `server.default_listen_address=0.0.0.0` to "make it reachable" therefore exposes a database with a guessable password over plaintext. Setting names below are Neo4j 5; Neo4j 4.x names differ. ## 1. Set the initial password before first start ```bash neo4j-admin dbms set-initial-password REPLACE_WITH_LONG_RANDOM_VALUE --require-password-change=false ``` This command is for use once, before the database's first start; the default minimum length is 8 characters (`dbms.security.auth_minimum_password_length`). The documentation warns against typing the password on the command line where it lands in shell history; prompt for it instead. Leave `dbms.security.auth_enabled` at its default `true`; the documentation reserves turning it off for recovery with all network access blocked. ## 2. Bind deliberately `server.default_listen_address` supplies the host part for every connector (`server.bolt.listen_address` defaults to `:7687`, `server.http.listen_address` to `:7474`, `server.https.listen_address` to `:7473`). Keep the default `localhost` unless remote clients are deliberate, then prefer a specific private address over `0.0.0.0`. The documentation notes that changing it may expose cluster ports and recommends overriding the cluster `listen_address` settings to `localhost` when clustering is not in use; the backup port (`server.backup.listen_address`, default `127.0.0.1:6362`) must stay off external interfaces. Firewall per [cloud-firewalls.md](cloud-firewalls.md) or [host.md](host.md), and widen only after steps 3 and 4. ```properties server.default_listen_address=203.0.113.10 ``` ## 3. TLS on Bolt and HTTPS, HTTP off Put a PKCS#8 PEM private key and certificate ([free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md)) under the policy directory, owned by `neo4j:neo4j`, key mode `0400`, certificate `0644`; legacy PKCS#1 keys (the PEM header that names an RSA key) are deprecated and must be converted. `server.bolt.tls_level=REQUIRED` refuses unencrypted Bolt (`OPTIONAL` keeps accepting it); `server.http.enabled=false` removes the plaintext 7474 endpoint. For machine clients that can hold certificates, `dbms.ssl.policy.bolt.client_auth=REQUIRE` adds mutual TLS. ```properties dbms.ssl.policy.bolt.enabled=true dbms.ssl.policy.bolt.base_directory=certificates/bolt dbms.ssl.policy.bolt.private_key=private.key dbms.ssl.policy.bolt.public_certificate=public.crt dbms.ssl.policy.bolt.client_auth=NONE server.bolt.tls_level=REQUIRED dbms.ssl.policy.https.enabled=true dbms.ssl.policy.https.base_directory=certificates/https dbms.ssl.policy.https.private_key=private.key dbms.ssl.policy.https.public_certificate=public.crt dbms.ssl.policy.https.client_auth=NONE server.https.enabled=true server.http.enabled=false ``` ## 4. Users and roles Create a user per application instead of sharing `neo4j` ([authentication.md](authentication.md)). Role-based access control (built-in roles `reader`, `editor`, `publisher`, `architect`, `admin`, plus custom roles) is documented for Enterprise Edition; Community Edition has native users and passwords but no role management, so it cannot give an application read-only access and the network boundary carries more weight. Failed logins lock an account for `dbms.security.auth_lock_time` (default `5s`) after `dbms.security.auth_max_failed_attempts` (default `3`). ```cypher CREATE USER app SET PASSWORD 'REPLACE_WITH_LONG_RANDOM_VALUE' CHANGE NOT REQUIRED; GRANT ROLE reader TO app; // Enterprise Edition; editor or publisher for writers ``` MFA: native login has no second factor. Enterprise Edition can delegate authentication to LDAP or an OIDC provider, where MFA is enforced at the identity provider ([mfa.md](mfa.md), [oidc-integration.md](oidc-integration.md)); otherwise mutual TLS is the possession factor for services and every human path to the host sits behind MFA. ## Verify Clients and drivers connect with `neo4j+s://`, which verifies the certificate; `neo4j+ssc://` accepts a self-signed certificate without verification and belongs in development only. ```bash ss -tlnp | grep -E '7474|7473|7687' # 7473 and 7687 only, on the intended address openssl s_client -connect neo4j.example.com:7687 memcached.md ====================================================================== # Memcached: bind privately; authentication and TLS are optional builds Memcached has no authentication by default, and its `-l` option defaults to `INADDR_ANY`, so a stock start listens on every interface on TCP 11211 and serves any client that connects. The project's own wording: memcached "does not spend much, if any, effort in ensuring its defensibility from random internet connections", so it "must not" be exposed to the internet or to untrusted users. The practical control is network isolation; SASL and TLS exist, but each needs a build compiled with that feature, and SASL alone sends credentials in the clear. ## 1. Bind to loopback or a private interface, UDP off ```bash memcached -l 127.0.0.1 -p 11211 -U 0 ``` `-l` accepts an address or `host:port`; the man page calls it "an important option to consider as there is no other way to secure the installation". `-U 0` keeps UDP disabled (the default since 1.5.6; UDP memcached was the amplifier in large reflection attacks). Put the same flags in your distribution's service configuration, and firewall 11211 per [cloud-firewalls.md](cloud-firewalls.md) or [host.md](host.md). For clients on other hosts, prefer a private network or a tailnet ([tailscale.md](tailscale.md)) over a public listener. ## 2. SASL authentication (binary protocol only) If the build was configured with `--enable-sasl` (memcached 1.4.3 and later), `-S` turns SASL on. It enables the SASL commands, forces the binary protocol only, and requires a successful authentication before other commands on a connection. Credentials come from the Cyrus SASL password database: ```bash saslpasswd2 -a memcached -c cacheuser memcached -l 10.0.0.5 -U 0 -S ``` The documentation requires the password file to be owned by, and readable only by, the user running memcached. It also states that SASL "does not provide encryption, but can provide authentication" and is meant to protect against neighbours and accidents inside a mostly trusted network, not to face the internet. Without TLS (step 3), the credential crosses the network in the clear. The text protocol has a separate token authentication (`-Y` authfile, sent as a fake `set` command with `username password` as the value) with the same limitation. ## 3. TLS (1.5.13 and later, build with `--enable-tls`) TLS requires a build configured with `--enable-tls` against OpenSSL 1.1.1 or later, and a client library that speaks TLS. It is off by default: ```bash memcached -l 10.0.0.5 -U 0 -S -Z \ -o ssl_chain_cert=/etc/memcached/tls/fullchain.pem,ssl_key=/etc/memcached/tls/privkey.pem ``` `-Z` (`--enable-ssl`) turns TLS on; `ssl_chain_cert` and `ssl_key` point at the PEM certificate chain and key ([free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md)). `-o ssl_verify_mode=2` with `-o ssl_ca_cert=/path/ca.pem` requires client certificates (mutual TLS), which is the strongest option memcached offers and the possession factor for machine clients ([machine-auth.md](machine-auth.md)). A `-l notls:127.0.0.1:11211` listener keeps a plaintext socket for local tooling only. `refresh_certs` reloads certificates without a restart, and `stats settings` shows the active `ssl_` values. MFA: there is no login for a person, so no second factor applies; human access to the host goes behind MFA per [mfa.md](mfa.md). ## Verify ```bash ss -tlnup | grep 11211 # 127.0.0.1 or the private address; no UDP line printf 'stats\r\nquit\r\n' | nc 127.0.0.1 11211 # works locally printf 'stats\r\nquit\r\n' | nc cache.example.com 11211 # from outside: connection refused or timeout openssl s_client -connect 10.0.0.5:11211 object-storage.md ====================================================================== # Object storage: S3, Cloudflare R2, Google Cloud Storage, Azure Blob, Supabase Storage AI projects put user uploads, datasets, and model files in buckets, and one public bucket or one over-broad policy leaks every object in it, silently, to anyone who guesses or scrapes a URL. Every provider below now defaults new buckets to private; the work is keeping them that way, granting access per principal, and sharing objects through short-lived signed URLs rather than by making anything public. Self-hosted MinIO is covered in [minio.md](minio.md). ## 1. Amazon S3 - New buckets and objects allow no public access, and Object Ownership defaults to "Bucket owner enforced", which disables ACLs; keep it that way and grant access only through bucket policies and IAM. - Turn on all four Block Public Access settings (`BlockPublicAcls`, `IgnorePublicAcls`, `BlockPublicPolicy`, `RestrictPublicBuckets`) at the account level as well as per bucket; the account setting wins even if someone loosens a bucket policy later. - A bucket policy is "public" if it grants to `"Principal": "*"` without a fixed condition (specific principal, `aws:SourceVpc`, `aws:SourceArn`, a narrow `aws:SourceIp`, and similar). Give each application its own IAM role with only the actions and prefixes it uses ([machine-auth.md](machine-auth.md)). - Share objects with presigned URLs and a short expiry: `aws s3 presign s3://example-bucket/model.safetensors --expires-in 600` (default 3600 seconds, maximum 604800). ```bash aws s3api get-public-access-block --bucket example-bucket # all four true aws s3api get-bucket-policy-status --bucket example-bucket # "IsPublic": false ``` IAM Access Analyzer for S3 lists every bucket in the account whose ACL, bucket policy, or access point policy grants public or cross-account access. ## 2. Cloudflare R2 - Buckets are never publicly accessible by default; public access is an explicit step, either a custom domain you control or a Cloudflare-managed `r2.dev` subdomain, which is rate-limited and for development only. Under the bucket's settings, keep the Public Development URL disabled and attach no custom domain unless the bucket is meant to be public. - Create R2 API tokens with the least permission: `Object Read only` or `Object Read & Write` scoped to specific buckets for applications; the `Admin` levels can create and delete buckets and belong to operators only. The secret access key is shown once, so store it in a secret manager ([secrets.md](secrets.md)). - R2 supports S3 presigned URLs, generated with your R2 token and SigV4, valid from 1 second to 7 days (604800 seconds); keep uploads and downloads on these rather than on a public bucket. ## 3. Google Cloud Storage - Enable uniform bucket-level access so ACLs are disabled and only IAM grants access; after 90 consecutive days it cannot be turned off, which is the point. - Enforce public access prevention on the bucket, or at project, folder, or organization level with the `storage.publicAccessPrevention` constraint; attempts to grant `allUsers` or `allAuthenticatedUsers` then fail with `412 Precondition Failed`, and anonymous requests to data get `401` or `403`. A bucket shows `enforced` or `inherited`. - Signed URLs (V4) expire after at most 604800 seconds (7 days); `gcloud storage sign-url --duration=1h` allows up to 12 hours with the caller's credentials or 7 days with a service-account private key. Public access prevention does not apply to signed URLs, so keep their durations short. ```bash gcloud storage buckets update gs://example-bucket --uniform-bucket-level-access --public-access-prevention # boolean flags; describe then reads back enforced gcloud storage buckets describe gs://example-bucket # uniform_bucket_level_access: true, public access prevention enforced ``` ## 4. Azure Blob Storage - Anonymous access is prohibited by default for Resource Manager storage accounts. Keep the account property `allowBlobPublicAccess` at `false` ("Allow Blob anonymous access: Disabled" under Settings > Configuration); it overrides any container set to Container or Blob access, so a per-container mistake cannot open data. Check it with `az storage account show --name examplestorage --resource-group example-rg --query allowBlobPublicAccess --output tsv`. - Prefer a user delegation SAS (secured by Microsoft Entra credentials) over service or account SAS signed with the account key, use HTTPS only, grant the least permission (read-only, a single blob), and use near-term expiry; a SAS expiration policy on the account warns when a longer one is generated. Consider disallowing Shared Key access so nobody can mint account-key SAS at all. - Azure Policy with the `Microsoft.Storage/storageAccounts/allowBlobPublicAccess` field audits or denies accounts that allow anonymous access. ## 5. Supabase Storage - Buckets are private by default; a public bucket means anyone with the URL can read the file, so use one only for assets that are meant to be public. - Access to a private bucket is governed by row level security policies on `storage.objects`, and without policies Storage allows no uploads at all. Write policies per operation and scope them to the owner, for example: ```sql create policy "Individual user Access" on storage.objects for select to authenticated using ( (select auth.jwt()->>'sub') = owner_id ); ``` - Share private objects with `supabase.storage.from('bucket').createSignedUrl('path.pdf', 3600)` (seconds) from server code; `getPublicUrl` works only for public buckets. Signed URLs stay valid until they expire even if you rotate Auth keys, so keep them short. The service-role key bypasses RLS and never reaches a browser ([firebase-supabase.md](firebase-supabase.md)). ## Verify ```bash curl -sI https://example-bucket.s3.amazonaws.com/model.safetensors # 403, never 200 URL="$(aws s3 presign s3://example-bucket/model.safetensors --expires-in 60)" # signs a GET, so test with GET curl -sS -o /dev/null -w '%{http_code}\n' "$URL" # 200 now sleep 61; curl -sS -o /dev/null -w '%{http_code}\n' "$URL" # 403 once the minute has passed ``` - An anonymous request to any object URL is denied (S3 returns `403`; GCS `401` or `403`; Azure `401`, or `409` when the account disallows anonymous access; Supabase private buckets return an error, not the file). - The provider's public-access view is empty: IAM Access Analyzer for S3 shows no public buckets, `gcloud storage buckets describe` shows public access prevention `enforced`, the Azure Resource Graph query for `allowBlobPublicAccess` shows `false` on every account, and R2 buckets show no Public Development URL or custom domain. - Application credentials are scoped to one bucket or prefix, and no root, account-key, or service-role credential appears in client code or the repository. ## Common mistakes - Making a bucket public to fix a broken download link, when the fix was a signed URL. - A presigned URL or SAS with a multi-day expiry pasted into a chat or ticket; it is a credential until it expires. ## Sources (checked September 2026) - S3 Block Public Access (four settings, defaults, meaning of "public", IAM Access Analyzer): https://docs.aws.amazon.com/AmazonS3/latest/userguide/access-control-block-public-access.html and https://docs.aws.amazon.com/AmazonS3/latest/userguide/configuring-block-public-access-bucket.html - S3 Object Ownership (Bucket owner enforced default, ACLs disabled): https://docs.aws.amazon.com/AmazonS3/latest/userguide/about-object-ownership.html - AWS CLI `s3 presign` (`--expires-in` default and maximum): https://docs.aws.amazon.com/cli/latest/reference/s3/presign.html - Cloudflare R2 public buckets, API tokens, presigned URLs: https://developers.cloudflare.com/r2/buckets/public-buckets/ , https://developers.cloudflare.com/r2/api/tokens/ , https://developers.cloudflare.com/r2/api/s3/presigned-urls/ - Google Cloud Storage uniform bucket-level access, public access prevention, signed URLs, `gcloud storage sign-url`: https://docs.cloud.google.com/storage/docs/uniform-bucket-level-access , https://docs.cloud.google.com/storage/docs/using-uniform-bucket-level-access , https://docs.cloud.google.com/storage/docs/public-access-prevention , https://docs.cloud.google.com/storage/docs/access-control/signed-urls , https://docs.cloud.google.com/sdk/gcloud/reference/storage/sign-url - Azure Blob anonymous access remediation and SAS overview: https://learn.microsoft.com/en-us/azure/storage/blobs/anonymous-read-access-prevent and https://learn.microsoft.com/en-us/azure/storage/common/storage-sas-overview - Supabase Storage buckets, access control, and downloads: https://supabase.com/docs/guides/storage/buckets/fundamentals , https://supabase.com/docs/guides/storage/security/access-control , https://supabase.com/docs/guides/storage/serving/downloads ====================================================================== ==> nats.md ====================================================================== # NATS and JetStream: authentication, TLS, and the monitoring port NATS accepts client connections on 4222 with no authentication configured by default, and a separately enabled HTTP monitoring endpoint (conventionally 8222) that reveals connected clients, subjects, and traffic with no login of its own. Both need explicit configuration. JetStream (the persistence layer for streams and consumers) is a feature of the same server process: it adds no listening port of its own, and its state surfaces through the same monitoring endpoint (`/jsz`) rather than a dedicated one. ## 1. Require authentication Inside an `authorization { }` block in the server config, pick one mechanism: a shared token, per-user password, or NKEYS (public-key identity, no password on the wire): ``` authorization { users: [ { user: app, password: "REPLACE_WITH_LONG_RANDOM_PASSWORD" } { nkey: UAPZQH4MNJCOVEJFERB3NFSIROQ5RE7CGBEPKAZSB6QB7IQHBKXHZPVP } ] } ``` Decentralized JWT-based auth (accounts and users signed by an operator, for multi-tenant deployments) is documented separately. Do not set `no_auth_user`, which names a user that unauthenticated connections are admitted as, unless an anonymous path is deliberate; it is easy to leave in place after testing and forget it grants access. ## 2. Scope what each user can do A user with no `permissions` block is unrestricted. Give each identity subject-level allow lists so a compromised credential cannot publish or subscribe everywhere: ``` authorization { users: [ { user: order-svc password: "REPLACE_WITH_LONG_RANDOM_PASSWORD" permissions: { publish: { allow: ["orders.>"] } subscribe: { allow: ["_INBOX.>"] } } } ] } ``` The moment a `permissions` block writes an `allow` list, every subject not on it is denied; `deny` entries take precedence over `allow` when both are present. ## 3. Enable TLS ``` tls { cert_file: "/etc/nats/certs/server-cert.pem" key_file: "/etc/nats/certs/server-key.pem" ca_file: "/etc/nats/certs/ca.pem" verify: true } ``` Certificates per [free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md). `verify: true` requires and verifies a client certificate against `ca_file` (mutual TLS). `verify_and_map: true` does the same and also derives the connecting user's identity from the certificate (email, DNS, or URI SANs, or the distinguished name); use one or the other, not both. ## 4. Keep the monitoring port private The HTTP monitoring endpoint is off unless configured (`http_port: 8222` in the config file, or `-m 8222` on the command line; `https_port` serves the same data over TLS). It answers `/varz`, `/connz`, `/routez`, and, with JetStream enabled, `/jsz`, as JSON, and the documentation is direct about the risk: "anyone who can reach `:8222` can read `/connz` and see your users, subjects, and traffic." Bind it to loopback or a private network, or place it behind an authenticating proxy; do not publish it. ## Verify The `nats` CLI reads a saved context and environment variables (`NATS_URL`, `NATS_USER`, `NATS_PASSWORD`, and similar) before falling back to any default, so a credential-free test has to neutralize both or it can silently inherit credentials from whatever context happens to be active. ```bash ss -tlnp | grep -E ':(4222|8222) ' # 4222 as intended, 8222 loopback/private only nats pub orders.created hello --tlsca /etc/nats/certs/ca.pem --tlscert REPLACE_WITH_CLIENT_CERT_FILE --tlskey REPLACE_WITH_CLIENT_KEY_FILE --user order-svc --password REPLACE_WITH_LONG_RANDOM_PASSWORD # allowed subject, valid credentials: succeeds nats pub other.subject hello --tlsca /etc/nats/certs/ca.pem --tlscert REPLACE_WITH_CLIENT_CERT_FILE --tlskey REPLACE_WITH_CLIENT_KEY_FILE --user order-svc --password REPLACE_WITH_LONG_RANDOM_PASSWORD # subject outside the allow list: fails nats --context "" --server nats://REPLACE_WITH_NATS_HOST:4222 pub orders.created hello --tlsca /etc/nats/certs/ca.pem --tlscert REPLACE_WITH_CLIENT_CERT_FILE --tlskey REPLACE_WITH_CLIENT_KEY_FILE # empty context, explicit server, no --user/--password: fails, and cannot inherit credentials from a saved context or NATS_URL/NATS_USER/NATS_PASSWORD curl -s http://monitor.example.com:8222/connz # connection refused/timeout from outside ``` ## Common mistakes - Leaving `no_auth_user` set after testing, which quietly readmits anonymous clients. - Exposing 8222 (or `https_port`) on a public interface because it "is just monitoring." - A user with no `permissions` block, which is unrestricted rather than denied. ## Sources (checked September 2026) - Securing NATS overview: https://docs.nats.io/running-a-nats-service/configuration/securing_nats - Authentication basics (token, user/password, nkeys, no_auth_user): https://docs.nats.io/learn/security/authentication-basics - Authorization (subject permissions, allow/deny) and Encryption/TLS (tls block): https://docs.nats.io/learn/security/authorization and https://docs.nats.io/learn/security/encryption - TLS Authentication (verify vs verify_and_map): https://docs.nats.io/running-a-nats-service/configuration/securing_nats/auth_intro/tls_mutual_auth - Monitoring (http_port/https_port, /varz, /connz, /routez, /jsz): https://docs.nats.io/running-a-nats-service/configuration/monitoring - JetStream concepts: https://docs.nats.io/nats-concepts/jetstream ====================================================================== ==> search-engines.md ====================================================================== # Search engines for RAG: Meilisearch and Typesense Both back RAG pipelines and site search, and both hand out a bootstrap or default key that is full admin over every index; Meilisearch also ships a keyless development mode meant for a laptop, but Typesense requires an API key from the moment it starts, with no keyless mode of its own. Ship an unprotected Meilisearch dev-mode instance, or leak either engine's bootstrap or default key, and the whole corpus, every document your RAG pipeline embedded, is readable and writable by whoever has it. ## Meilisearch Meilisearch runs in two modes. In development mode it answers without a key by default, but development mode can still be protected by launching with `MEILI_MASTER_KEY` set; production mode requires it, together with `--env production`. Either way the master key (at least 16 bytes) is the credential everything else derives from. From it, Meilisearch generates four default API keys: a Default Search API Key (search only, all indexes), a Default Admin API Key (full access except key management), a Default Read-Only Admin API Key (read-only access to all indexes, documents, and settings), and a Default Chat API Key (search and chat completions). It also supports scoped API keys you create yourself and tenant tokens: "short-lived, client-side tokens derived from API keys" for per-end-user search restrictions without shipping a standing key to each user. Use the Default Search API Key (or a scoped key) in front-end code; never an admin key, the read-only admin key, or the master key. Meilisearch does not terminate HTTPS itself in the typical deployment, so put a reverse proxy or your platform's TLS in front ([nginx.md](nginx.md), [caddy.md](caddy.md), [cloudflare.md](cloudflare.md)) and restrict network access with firewall rules as an additional layer ([cloud-firewalls.md](cloud-firewalls.md)). ## Typesense Typesense requires a bootstrap key at startup, set with the `--api-key` server parameter (a required parameter; the server will not start without it); that key has "admin permissions on all endpoints and data." Use it only to create a permanent admin key through the `/keys` API, then stop using the bootstrap key day to day so it can be rotated without a restart-time outage. For anything that runs in a browser, generate a scoped, search-only key through the same `/keys` endpoint: ```json { "actions": ["documents:search"], "collections": ["*"] } ``` Narrow `collections` to a name or regex to limit a key to specific collections, embed a `filter_by` clause in a scoped key to restrict it to specific documents (Typesense: "Users will not be able to override the filter embedded inside the scoped API Key"), and use `include_fields`/`exclude_fields` to hide sensitive fields such as billing data from a given key. Typesense's own guidance is direct: "Never expose your Admin API Key or Bootstrap API Key to your frontend application as anyone with access to it will be able to write data into your collection." Set `expires_at` on browser-facing keys so a leaked one has a shelf life. Typesense's cloud offering terminates TLS for you. A self-managed cluster can also terminate TLS natively with the `--ssl-certificate` and `--ssl-certificate-key` server parameters, which Typesense documents as sufficient for direct internet exposure; this guide still defaults to the same reverse-proxy or platform TLS pattern as Meilisearch above for consistency and because a proxy already handles certificate renewal, but native termination is a documented, supported alternative. ## The pattern, either engine The credential that goes into a browser must be search-only and, ideally, scoped to what that specific user or page needs (a tenant token in Meilisearch, a scoped key with `filter_by` in Typesense). The admin or bootstrap key stays server-side, in the platform's secret store, never in client bundles or repository history ([secrets.md](secrets.md)). ## Verify ```bash curl -s -o /dev/null -w '%{http_code}\n' -X POST https://search.example.com/indexes/movies/search -H 'Content-Type: application/json' --data-raw '{"q":"ninja"}' # Meilisearch, no key: 401 curl -s -X POST https://search.example.com/indexes/movies/search -H "Authorization: Bearer REPLACE_WITH_SEARCH_KEY" -H 'Content-Type: application/json' --data-raw '{"q":"ninja"}' # search key: search works curl -s -o /dev/null -w '%{http_code}\n' -X POST https://search.example.com/indexes -H "Authorization: Bearer REPLACE_WITH_SEARCH_KEY" -H 'Content-Type: application/json' --data-raw '{"uid":"movies"}' # search key attempting to create an index: 403 curl -s -o /dev/null -w '%{http_code}\n' "https://search.example.com/collections/products/documents/search?q=stark&query_by=company_name" # Typesense, no key: 401 curl -s "https://search.example.com/collections/products/documents/search?q=stark&query_by=company_name" -H "X-TYPESENSE-API-KEY: REPLACE_WITH_SEARCH_ONLY_KEY" # search-only key: search works curl -s -o /dev/null -w '%{http_code}\n' -X POST https://search.example.com/collections -H "X-TYPESENSE-API-KEY: REPLACE_WITH_SEARCH_ONLY_KEY" -H 'Content-Type: application/json' --data-raw '{"name":"products"}' # search-only key attempting to create a collection: 403 ``` Grep the client bundle and repository history for the admin/master/bootstrap key; it should never appear outside the server-side secret store. ## Common mistakes - Shipping a Meilisearch instance without `MEILI_MASTER_KEY` and `--env production` because dev mode "worked fine" in testing. - Putting the Typesense bootstrap key or Meilisearch admin key straight into front-end JavaScript instead of minting a scoped search key. - A scoped key with no `filter_by` or `collections` restriction, which searches everything the admin key can see. ## Sources (checked September 2026) - Meilisearch master API keys (MEILI_MASTER_KEY, the four default API keys): https://www.meilisearch.com/docs/resources/self_hosting/security/master_api_keys - Typesense data access control (bootstrap api-key, /keys, actions, collections, filter_by, include_fields/exclude_fields, expires_at): https://typesense.org/docs/guide/data-access-control.html ====================================================================== ==> sqlite.md ====================================================================== # SQLite in deployment: the file is the exposure (plus Turso and Litestream) SQLite has no server process, no network listener, and no built-in authentication: its documented security model rests entirely on the filesystem, warning that "any database file which might have ever been writable by an agent in a different security domain should be treated as suspect." In deployment this means the exposure is never SQLite itself, it is wherever the `.db` file ends up: under a web root, inside a git repository, world-readable, or replicated to a public bucket. ## 1. Keep the file out of anything that serves it A `.db` or `.sqlite` file inside a directory a web server or static host points at is downloadable by URL like any other file under the web root; treat it with the same deny rules as backups and dumps ([web-exposure.md](web-exposure.md)). Keep the database path outside the document root entirely, for example `/var/lib/myapp/app.db`, never `public/app.db` or `static/app.db`. ## 2. Keep the file out of git A committed `.db` file ships every row to anyone who clones the repository, permanently, even after a later commit deletes it. Add `*.db`, `*.sqlite`, `*.sqlite3`, and the WAL/SHM sidecar files (`*.db-wal`, `*.db-shm`) to `.gitignore` before the first commit, and scan for a copy that already leaked per [secrets.md](secrets.md). ## 3. File permissions Restrict the database file and its containing directory to the app's own user (for example `chmod 600` on the file, `chmod 700` on the directory); any other local account or process on the host can otherwise open the file directly and read or write it, since there is no SQLite-side access control to stop it. ## 4. libSQL and Turso: the file becomes an HTTP endpoint Turso serves libSQL databases over HTTP, replacing the local file with a network service authenticated by a bearer token against a URL of the form `https://[databaseName]-[organizationSlug].turso.io`. Applications read `TURSO_DATABASE_URL` and `TURSO_AUTH_TOKEN` from the environment; the token is a secret exactly like an API key, never in the client bundle, never committed ([secrets.md](secrets.md)). ```bash turso db tokens create example-db --read-only --expiration 7d ``` `turso db tokens create` supports `-r`/`--read-only` to scope a token away from writes, and `-e`/`--expiration` to give it a lifetime (`never`, or a duration such as `7d3h`); issue a scoped, expiring token for anything that does not need full write access rather than reusing one long-lived full-access token everywhere. ## 5. Litestream and LiteFS: the replica destination is now part of the exposure Litestream continuously replicates the SQLite file to S3, Google Cloud Storage, Azure Blob Storage, and other supported destinations, authenticating to S3 the same way any AWS client does, with `AWS_ACCESS_KEY_ID` and `AWS_SECRET_ACCESS_KEY` in the environment. Litestream's own S3 guide scopes the IAM policy to the one bucket and prefix it needs rather than granting broad S3 access, which limits the damage if the credential leaks. The replica bucket needs the same private-by-default posture as any other bucket: see [object-storage.md](object-storage.md) for keeping it non-public and restoring through scoped credentials rather than a public URL. LiteFS replicates a SQLite file across a cluster's nodes rather than to object storage directly; its docs note the project is pre-1.0 and recommend regular off-site backups as a separate measure, and that backup destination should get the same bucket-privacy treatment as a Litestream replica. ## Verify ```bash curl -sI https://app.example.com/app.db # 404, never 200 git check-ignore -v app.db # prints a matching .gitignore rule stat -c '%a %U' /var/lib/myapp/app.db # 600, owned by the app user, not world-readable grep -rn "REPLACE_WITH_ACTUAL_TOKEN_VALUE" build dist .next/static; echo "exit: $?" # search for the literal token value copied from the secret store, not the env-var name a bundler already inlined away; exit 1 is the goal grep -rnE "eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}" build dist .next/static; echo "exit: $?" # a JWT-shaped token (Turso/libSQL tokens are JWTs) is a finding wherever it turns up; exit 2 means a path did not exist, not a clean result ``` A clean result here is evidence, not proof: it means neither pattern matched in the paths searched, not that the token cannot be present in some other form. A bundler could split, encode, or otherwise transform it, and a missing or misspelled directory can produce the same silence as a genuinely clean scan, so check the exit code and confirm the directories exist, not just the absence of output. ## Sources (checked September 2026) - SQLite security: https://www.sqlite.org/security.html - Turso HTTP API quickstart (`TURSO_DATABASE_URL`, `TURSO_AUTH_TOKEN`): https://docs.turso.tech/sdk/http/quickstart - Turso CLI `db tokens create` (`--read-only`, `--expiration`): https://docs.turso.tech/cli/db/tokens/create - Litestream guides (supported replica destinations): https://litestream.io/guides/ - Litestream S3 guide (credentials, scoped IAM policy): https://litestream.io/guides/s3/ - LiteFS overview (cluster replication, pre-1.0 status, backup recommendation): https://fly.io/docs/litefs/ ====================================================================== ==> surrealdb.md ====================================================================== # SurrealDB: authentication and TLS The quickest ways to start SurrealDB skip authentication or bind it to every interface, fine for a laptop demo and a real exposure the moment the same command runs on a routable server. ## 1. Root user and authenticated mode `surreal start` takes `--user`/`-u` and `--pass`/`-p` (also `SURREAL_USER`/`SURREAL_PASS`) to set "the initial database root user, applied only if no other root user exists": ```bash surreal start --user root --pass REPLACE_WITH_LONG_RANDOM_VALUE rocksdb:/data/mydb.db ``` The `--unauthenticated` flag (`SURREAL_UNAUTHENTICATED`) allows unauthenticated access instead; a guest connecting under it gets permissions equivalent to the `OWNER` role. Do not run it, or carry it from a local demo, on anything reachable beyond your own machine. ## 2. Bind privately `--bind`/`-b` (`SURREAL_BIND`) sets the listening address and defaults to `127.0.0.1:8000`, loopback only. Widen it deliberately, and only to a private address, for example `--bind 10.0.0.5:8000`; never bind an unauthenticated or root-only instance to `0.0.0.0`. SurrealDB's own security guidance says that if the database should only be reachable by other internal services, "expose SurrealDB exclusively to the internal network instead of deploying the service with a publicly addressable network interface." ## 3. User levels and access control System users (`DEFINE USER`) exist at three levels: root (visibility across every namespace and database), namespace, and database, each assigned a role of `OWNER`, `EDITOR`, or `VIEWER`. The docs warn plainly that "a root system user is not restricted by permissions at all, so it is the wrong credential to put in an application"; give an application its own namespace or database level user scoped to what it needs. Record users are different: they are rows in your own tables, authenticated through `DEFINE ACCESS ... TYPE RECORD` with custom `SIGNUP` and `SIGNIN` logic, and they "have no permissions" beyond what a `PERMISSIONS` clause on a table or field grants them, the same rules-are-the-security model as Firebase and Supabase ([firebase-supabase.md](firebase-supabase.md)): a table with no `PERMISSIONS` clause for a record user grants nothing by default. `DEFINE ACCESS` also supports `TYPE JWT` (trusting an external identity provider's tokens) and `TYPE BEARER` (per-client keys) for system-to-system authentication. ## 4. TLS `--web-crt` and `--web-key` serve SurrealDB's own interfaces over HTTPS directly. SurrealDB's own guidance also endorses delegating TLS termination to a load balancer or reverse proxy, per [nginx.md](nginx.md) or [caddy.md](caddy.md), or reaching the instance only over a tailnet ([tailscale.md](tailscale.md)). ## Verify ```bash ss -tlnp | grep 8000 # loopback or private address only, never 0.0.0.0 curl -sI http://127.0.0.1:8000/health # process is up surreal sql --endpoint http://127.0.0.1:8000 --namespace test --database test ``` The last command, run with no `--username`/`--password` against an instance started without `--unauthenticated`, must be refused rather than dropping into a session. A connection from outside the bound address must fail at the network layer, not just the application layer, and `curl -vI https://` should show a valid certificate chain wherever TLS terminates. ## Sources (checked September 2026) - SurrealDB CLI, `surreal start`: https://surrealdb.com/docs/surrealdb/cli/start - SurrealDB CLI, `surreal sql`: https://surrealdb.com/docs/surrealdb/cli/sql - SurrealDB security overview: https://surrealdb.com/docs/surrealdb/security - SurrealDB authentication overview: https://surrealdb.com/docs/learn/security/authentication/overview - SurrealDB security best practices: https://surrealdb.com/docs/learn/security/best-practices/security-best-practices ====================================================================== ==> jupyter.md ====================================================================== # Jupyter: password and TLS An exposed Jupyter server is remote code execution for whoever finds it. Jupyter Server (which also runs JupyterLab and Notebook 7) ships with token authentication on and binds to localhost; keep both properties when you change anything else. For multi-user or internet-facing use, prefer JupyterHub or access through [cloudflare.md](cloudflare.md) over exposing a single server directly. ## 1. Generate the config and set a password ```bash jupyter server --generate-config # writes ~/.jupyter/jupyter_server_config.py jupyter server password # prompts; stores the hashed password in jupyter_server_config.json ``` ## 2. Enable TLS Get a certificate per [free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md), then in `~/.jupyter/jupyter_server_config.py`: ```python c.ServerApp.certfile = '/absolute/path/to/cert.pem' c.ServerApp.keyfile = '/absolute/path/to/key.pem' ``` Or per invocation: ```bash jupyter lab --certfile=/path/cert.pem --keyfile=/path/key.pem ``` Once TLS is on, connect via `https://`; the server no longer answers plain `http://` usefully. ## 3. Exposure rules - Do not set `c.ServerApp.ip = '0.0.0.0'` (or `--ip 0.0.0.0`) without the password from step 1 **and** TLS from step 2 in place. - Never blank the token or password settings to make login prompts go away; that is exactly the configuration internet scanners look for. - A reverse proxy with its own auth ([nginx.md](nginx.md), [caddy.md](caddy.md)) or Cloudflare Access ([cloudflare.md](cloudflare.md)) in front of a loopback-bound Jupyter is a sound alternative to native TLS. It adds a second factor only if the Access policy or identity provider behind it is configured to require one; fronting alone does not. - MFA: the Jupyter password is single-factor, so the fronting options above are where the second factor comes from; multi-user deployments on JupyterHub can delegate login to an OIDC/OAuth provider that enforces MFA. Options in [mfa.md](mfa.md). ## 4. Verify ```bash curl -skI https://host:8888/ # answers over TLS # In a private browser window: the server asks for the password before showing any notebook. ss -tlnp | grep 8888 # bound to 127.0.0.1 unless deliberately exposed ``` ## Sources (checked September 2026) - Jupyter Server public server guide: https://jupyter-server.readthedocs.io/en/latest/operators/public-server.html ====================================================================== ==> ollama.md ====================================================================== # Ollama: it has no built-in authentication or TLS Ollama's API binds to `127.0.0.1:11434` by default. Setting `OLLAMA_HOST=0.0.0.0` exposes the full API (model execution, pull, delete) to the network with **no authentication and no TLS**; the self-hosted server provides neither (per the Ollama FAQ as of September 2026; verify against current docs before relying on this). Thousands of Ollama instances exposed this way are indexed by internet scanners. Rules: 1. Leave `OLLAMA_HOST` at its loopback default unless a protective layer is in front. 2. Never set `OLLAMA_HOST=0.0.0.0` on a machine with a public interface. "It is just a model server" still means free compute, model tampering, and data exfiltration for anyone who finds it. 3. Expose it only through an authenticated TLS proxy or tunnel, as below. ## Option A: reverse proxy with TLS and basic auth Keep Ollama on loopback; publish only the proxy. nginx (full context in [nginx.md](nginx.md)): ```nginx server { listen 443 ssl; server_name ollama.example.com; ssl_certificate /etc/letsencrypt/live/ollama.example.com/fullchain.pem; ssl_certificate_key /etc/letsencrypt/live/ollama.example.com/privkey.pem; ssl_protocols TLSv1.2 TLSv1.3; location / { auth_basic "Ollama"; auth_basic_user_file /etc/nginx/.htpasswd; # htpasswd -B proxy_pass http://127.0.0.1:11434; proxy_set_header Host localhost:11434; proxy_read_timeout 300s; # model responses can be slow } } ``` Caddy equivalent ([caddy.md](caddy.md)): ```caddyfile ollama.example.com { basic_auth { admin $2a$14$REPLACE_WITH_HASH_FROM_caddy_hash-password } reverse_proxy 127.0.0.1:11434 } ``` Clients then call `https://ollama.example.com` with the basic-auth credentials. For clients that only send bearer tokens, enforce the token at the proxy: ```nginx location / { if ($http_authorization != "Bearer REPLACE_WITH_LONG_RANDOM_TOKEN") { return 401; } proxy_pass http://127.0.0.1:11434; } ``` Generate the token per [authentication.md](authentication.md) and keep it out of the repository. ## Option B: Cloudflare Tunnel with Access Follow [cloudflare.md](cloudflare.md) with the tunnel route pointed at `http://localhost:11434` and an Access policy (or service token for API clients) on the hostname. The Ollama FAQ itself documents fronting the server with a tunnel; adding Access is what makes it authenticated. MFA: Ollama has no login of its own, so a second factor can only come from the fronting layer: an Access policy backed by an MFA-enforcing identity provider, or an [Authelia](https://www.authelia.com/)-protected proxy. Options in [mfa.md](mfa.md). ## Verify ```bash ss -tlnp | grep 11434 # 127.0.0.1 only curl -s http://:11434/api/tags # from another machine: connection refused curl -s https://ollama.example.com/api/tags # 401 without credentials curl -su admin https://ollama.example.com/api/tags # model list with credentials ``` ## Sources (checked September 2026) - Ollama FAQ (bind address, `OLLAMA_HOST`, proxy examples): https://docs.ollama.com/faq - Ollama repository: https://github.com/ollama/ollama ====================================================================== ==> open-webui.md ====================================================================== # Open WebUI: signup control, TLS, and MFA Open WebUI has account-based authentication built in; the risks are open signup on an exposed instance and running it on plain HTTP. It provides no TLS of its own, so encryption comes from a fronting layer. ## 1. Control who can register Environment variables (defaults per the Open WebUI reference): ``` ENABLE_SIGNUP=false # default true; disable once your accounts exist (persisted, see below) DEFAULT_USER_ROLE=pending # the default; new accounts wait for admin approval # other values: user, admin ``` `ENABLE_SIGNUP` is a persisted setting: the reference marks it a `ConfigVar`, which means the value is written to the database on first launch and on later starts the stored value wins over the environment, unless `ENABLE_PERSISTENT_CONFIG=false` (default `true`). On an instance that has already started, change signup in the Admin panel rather than in the environment, then confirm the change took effect (Verify below). With signup left on, keep `DEFAULT_USER_ROLE=pending` so a stranger who registers gets no access until approved. An admin account can also be created at startup by setting `WEBUI_ADMIN_EMAIL` together with `WEBUI_ADMIN_PASSWORD` (supply the password via the environment, not a compose file in git; see [secrets.md](secrets.md)). For SSO, the reference documents OAuth/OIDC settings plus `ENABLE_PASSWORD_AUTH=false` to turn off password login once SSO works; enforcing MFA then happens at the identity provider ([mfa.md](mfa.md)). ## 2. Bind privately and add TLS in front ```bash docker run -d -p 127.0.0.1:3000:8080 ghcr.io/open-webui/open-webui:main ``` Publish it through [caddy.md](caddy.md)/[nginx.md](nginx.md) with a certificate from [free-certificates.md](free-certificates.md), or through a tunnel with login in front ([cloudflare.md](cloudflare.md), [tailscale.md](tailscale.md)). Never expose port 8080 directly: login forms over plain HTTP send passwords in cleartext. ## 3. Verify ```bash ss -tlnp | grep 3000 # loopback only curl -sI https://chat.example.com/ # serves over TLS # In a private browser window: login page appears; with ENABLE_SIGNUP=false the # sign-up option is absent and an attempt to register is refused. With signup on, # registering a new account yields a pending/unapproved user, not access. ``` ## Sources (checked September 2026) - Open WebUI environment configuration reference: https://docs.openwebui.com/reference/env-configuration - Open WebUI repository: https://github.com/open-webui/open-webui ====================================================================== ==> litellm.md ====================================================================== # LiteLLM proxy: master key and virtual keys A LiteLLM proxy fronts paid model APIs, so an exposed, keyless instance spends your provider credits for whoever finds it. Authentication is built in and must be switched on before anything else. ## 1. Set the master key In `config.yaml` under `general_settings: master_key`, or via the environment (preferred; see [secrets.md](secrets.md)): ```bash export LITELLM_MASTER_KEY="sk-REPLACE_WITH_LONG_RANDOM_VALUE" # must start with sk- ``` The master key is the root credential for the proxy; it belongs to the operator only and never to client applications. ## 2. Issue virtual keys per application Virtual keys need a PostgreSQL database: set `DATABASE_URL=postgresql://user:password@host:5432/dbname` in the environment (or `database_url` under `general_settings`) before `/key/generate` will work. ```bash curl https://llm.example.com/key/generate \ -H "Authorization: Bearer $LITELLM_MASTER_KEY" \ -H "Content-Type: application/json" \ -d '{"key_alias": "app-frontend"}' ``` Each app gets its own virtual key, which can be revoked or budgeted independently; LiteLLM's docs cover per-key models, budgets, and expiry. Clients send the virtual key in the `Authorization` header (the header name is configurable via `litellm_key_header_name`). ## 3. Bind privately and add TLS in front Run the proxy on loopback (or a private container network) and publish it only through a TLS layer: [caddy.md](caddy.md)/[nginx.md](nginx.md) with a certificate from [free-certificates.md](free-certificates.md), or [cloudflare.md](cloudflare.md)/[tailscale.md](tailscale.md) for no-open-port setups. Bearer keys over plain HTTP are compromised on first use. For human access to the LiteLLM admin UI, add MFA at the fronting layer ([mfa.md](mfa.md)). ## 4. Verify ```bash curl -sS -o /dev/null -w '%{http_code}\n' https://llm.example.com/v1/models # 401 without a key curl -s https://llm.example.com/v1/models -H "Authorization: Bearer " # model list ss -tlnp | grep 4000 # loopback only ``` ## Sources (checked September 2026) - LiteLLM proxy virtual keys (master_key, /key/generate, header name): https://docs.litellm.ai/docs/proxy/virtual_keys ====================================================================== ==> model-servers.md ====================================================================== # Model servers: llama.cpp, vLLM, TGI, SGLang, Triton, and LM Studio Self-hosted model servers follow the [ollama.md](ollama.md) pattern: exposing one means someone else's prompts run on your GPU. Most default to local use, but TGI and Triton bind to `0.0.0.0` out of the box, and Triton has no authentication at all. Keep every server on loopback or a private network, require an API key where the server supports one, and terminate TLS in front. ## llama.cpp (llama-server) `llama-server` listens on `127.0.0.1:8080` by default; keep that bind. Require a key: ```bash llama-server -m model.gguf --api-key "$LLAMA_API_KEY" # --api-key accepts a comma-separated list for multiple keys ``` Native TLS exists when the binary is built with OpenSSL (`-DLLAMA_OPENSSL=ON`): `--ssl-key-file` and `--ssl-cert-file` take PEM files ([self-signed.md](self-signed.md) or [free-certificates.md](free-certificates.md)). A reverse proxy per [nginx.md](nginx.md)/[caddy.md](caddy.md) is the alternative when your build lacks SSL support. ## vLLM (OpenAI-compatible server) vLLM's server supports requiring an API key; check `vllm serve --help` on your installed version for the current option name (the docs at https://docs.vllm.ai/ document it; this guide avoids pinning the flag because vLLM's CLI moves quickly). vLLM does not terminate TLS for you in typical deployments, so front it with a TLS proxy or tunnel and keep the server itself on loopback or a private network. ## Hugging Face Text Generation Inference (TGI) `text-generation-launcher` listens on `0.0.0.0:3000` by default (`--hostname`, env `HOSTNAME`; `--port`, env `PORT`), so a bare TGI container answers on every interface. Bind it to loopback, or publish nothing from the container network except the proxy: Lifecycle note, as of September 2026: the TGI repository is in maintenance mode and was archived on 2026-03-21 (read-only). Hugging Face recommends vLLM, SGLang, or local engines such as llama.cpp going forward. A server that no longer receives fixes belongs behind the same controls as any other, and on a migration list. ```bash text-generation-launcher --model-id REPLACE_WITH_MODEL_ID --hostname 127.0.0.1 --port 3000 ``` The launcher reference lists `--api-key` (env `API_KEY`) without describing it. The router source shows what it does: when set, every inference request must carry a matching `Authorization: Bearer ` header or receives 401, while the health, info, and metrics routes stay unauthenticated. Treat it as a second layer and enforce the bearer check at the proxy too (pattern in [ollama.md](ollama.md)). The launcher has no TLS option, so front TGI per [nginx.md](nginx.md)/[caddy.md](caddy.md). The Prometheus listener (`--prometheus-port`, default 9000) is unauthenticated as well; keep it private. ## SGLang `python -m sglang.launch_server` listens on `127.0.0.1:30000` by default (`--host`, `--port`); keep that bind. `--api-key` sets the key the OpenAI-compatible endpoints require, and `--admin-api-key` separately protects administrative endpoints (weight updates, cache flush, `/server_info`), which then require `Authorization: Bearer `: ```bash python -m sglang.launch_server --model-path REPLACE_WITH_MODEL_PATH --api-key "$SGLANG_API_KEY" --admin-api-key "$SGLANG_ADMIN_KEY" ``` Native TLS exists: `--ssl-keyfile` and `--ssl-certfile` take PEM files ([self-signed.md](self-signed.md) or [free-certificates.md](free-certificates.md)), `--ssl-ca-certs` names a CA bundle, and `--enable-ssl-refresh` hot-reloads renewed certificates. A reverse proxy remains the simpler choice when you already run one. ## NVIDIA Triton Inference Server `tritonserver` starts three listeners on `0.0.0.0`: HTTP on 8000, gRPC on 8001, and Prometheus metrics on 8002. It has no built-in authentication. NVIDIA's secure deployment guidance is that Triton is a microservice that is "not exposed directly to an untrusted network": a dedicated gateway or proxy (NGINX, Envoy, Istio, Kong are the examples given) handles authorization, access control, and encryption, and Triton "handles only trusted, validated requests". Bind each listener privately and disable the protocols you do not use: ```bash tritonserver --model-repository=/models --http-address=127.0.0.1 --grpc-address=127.0.0.1 --metrics-address=127.0.0.1 ``` `--allow-http` and `--allow-grpc` default to true; NVIDIA recommends setting either to false when not required, and `--allow-metrics` switches off the metrics listener. For gRPC, `--grpc-use-ssl` with `--grpc-server-cert` and `--grpc-server-key` enables a TLS channel, and `--grpc-use-ssl-mutual` requires client certificates. HTTP has no TLS option; the proxy provides it. `--http-restricted-api` and `--grpc-restricted-protocol` fence the model-control APIs behind a shared-secret header, a useful second layer but not a substitute for the gateway. ## LM Studio (local server) LM Studio's developer server is a desktop feature. The documentation addresses it at `http://localhost:1234` throughout (the port is a field in Developers Page > Server Settings), and "By default, LM Studio does not require authentication for API requests." The "Serve on Local Network" switch (or `lms server start --bind 0.0.0.0`) rebinds it to every interface; LM Studio's own note reads: "Any bind other than 127.0.0.1 exposes the server beyond localhost; we recommend enabling authentication." Leave that switch off. If another machine must reach it, first enable "Require Authentication" (LM Studio 0.4.0 or newer) and create a token under "Manage Tokens"; clients then send `Authorization: Bearer `. The server settings list no TLS option, so anything beyond the local machine goes through a tailnet ([tailscale.md](tailscale.md)) or an authenticated TLS proxy, never a port-forward. ## The pattern, whatever the server 1. Bind to `127.0.0.1` (or a private container network); confirm with `ss -tlnp`. 2. Require a per-client API key at the server where supported, or at the proxy otherwise (bearer-token check per [ollama.md](ollama.md)); generate keys per [authentication.md](authentication.md). 3. TLS in front: [caddy.md](caddy.md), [nginx.md](nginx.md), [cloudflare.md](cloudflare.md), or [tailscale.md](tailscale.md). 4. Human-facing UIs on top of these servers ([open-webui.md](open-webui.md)) carry their own login and MFA ([mfa.md](mfa.md)). ## Verify ```bash ss -tlnp | grep -E ':(8080|8000|8001|8002|3000|9000|30000|1234) ' # loopback only curl -s https://models.example.com/v1/models # 401 without a key curl -s https://models.example.com/v1/models -H "Authorization: Bearer " # succeeds ``` ## Sources (checked September 2026) - llama.cpp server README (defaults, --api-key, SSL flags): https://github.com/ggml-org/llama.cpp/blob/master/tools/server/README.md - vLLM documentation: https://docs.vllm.ai/ - TGI launcher arguments (--hostname, --port, --api-key, --prometheus-port): https://huggingface.co/docs/text-generation-inference/reference/launcher - TGI router source (what --api-key enforces): https://github.com/huggingface/text-generation-inference/blob/main/router/src/server.rs - TGI repository (maintenance-mode notice, archived 2026-03-21): https://github.com/huggingface/text-generation-inference - SGLang server arguments (--host, --port, --api-key, --admin-api-key, SSL flags; docs.sglang.ai redirects here): https://docs.sglang.io/advanced_features/server_arguments.html - Triton secure deployment considerations: https://docs.nvidia.com/deeplearning/triton-inference-server/user-guide/docs/customization_guide/deploy.html - Triton quickstart (default listeners on 8000, 8001, 8002): https://github.com/triton-inference-server/server/blob/main/docs/getting_started/quickstart.md - Triton inference protocols (gRPC SSL flags, restricted APIs): https://github.com/triton-inference-server/server/blob/main/docs/customization_guide/inference_protocols.md - Triton command line parser (address and port flags with defaults): https://github.com/triton-inference-server/server/blob/main/src/command_line_parser.cc - LM Studio local server: https://lmstudio.ai/docs/developer/core/server - LM Studio serve on local network: https://lmstudio.ai/docs/developer/core/server/serve-on-network - LM Studio server settings: https://lmstudio.ai/docs/developer/core/server/settings - LM Studio authentication: https://lmstudio.ai/docs/developer/core/authentication - LM Studio OpenAI compatibility (localhost:1234 examples): https://lmstudio.ai/docs/developer/openai-compat ====================================================================== ==> gradio.md ====================================================================== # Gradio: launch() authentication and TLS Gradio binds to `127.0.0.1` by default. Two launch choices create exposure: `server_name="0.0.0.0"` (all interfaces) and `share=True` (a public `*.gradio.live` URL through Gradio's relay). Neither is acceptable without authentication. ## 1. Require a login `launch()` takes credentials directly: ```python import os demo.launch( auth=(os.environ["GRADIO_USER"], os.environ["GRADIO_PASS"]), auth_message="Authorized users only", ) ``` `auth` also accepts a list of `(user, password)` tuples or a callable `f(username, password) -> bool`, which lets you check hashed credentials per [authentication.md](authentication.md). Keep the credentials in environment variables, not in the script. MFA: `auth` is single-factor. The callable form allows a TOTP step (for example, verify a [pyotp](https://github.com/pyauth/pyotp) code appended to the password); fronting the app with Cloudflare Access or an Authelia-protected proxy is the cleaner route. Options in [mfa.md](mfa.md). ## 2. Enable TLS For a public deployment, prefer a reverse proxy or tunnel in front of a loopback-bound Gradio app: [caddy.md](caddy.md), [nginx.md](nginx.md), or [cloudflare.md](cloudflare.md). Gradio can also serve HTTPS itself with a certificate from [free-certificates.md](free-certificates.md) or [self-signed.md](self-signed.md): ```python demo.launch( server_name="0.0.0.0", server_port=8443, ssl_certfile="/path/cert.pem", ssl_keyfile="/path/key.pem", ssl_verify=False, # only for self-signed certificates; skips validating your own cert auth=(os.environ["GRADIO_USER"], os.environ["GRADIO_PASS"]), ) ``` `ssl_keyfile_password` exists for encrypted keys. `ssl_verify=False` here affects how the launcher checks its own certificate; it is needed for self-signed certificates and unnecessary with a CA-issued one. ## 3. share=True is publication `share=True` publishes the app at a random public URL for anyone who obtains the link, with your machine executing the requests. Use it only for short demos, always combined with `auth`, and shut it down afterwards. It is not a deployment mechanism; for persistent authenticated remote access use [cloudflare.md](cloudflare.md). ## 4. Verify ```bash ss -tlnp | grep 7860 # loopback unless deliberately exposed curl -sI https://gradio.example.com/ # succeeds over TLS # In a private browser window: the login form appears before the app. ``` ## Sources (checked September 2026) - Gradio Blocks.launch() parameters (auth, auth_message, ssl_certfile, ssl_keyfile, ssl_keyfile_password, ssl_verify, server_name, share): https://www.gradio.app/docs/gradio/blocks ====================================================================== ==> streamlit.md ====================================================================== # Streamlit: TLS and authentication Streamlit apps have no access control unless you add it, and `streamlit run` listens on the network by default. Decide both layers before exposing an app. ## 1. TLS Preferred: keep Streamlit on loopback and terminate TLS in a reverse proxy or tunnel ([caddy.md](caddy.md), [nginx.md](nginx.md), [cloudflare.md](cloudflare.md)): ```toml # .streamlit/config.toml [server] address = "127.0.0.1" ``` Streamlit can serve HTTPS itself via `server.sslCertFile` and `server.sslKeyFile`, but its own documentation says not to use this in production ("It has not gone through security audits or performance tests") and to prefer a reverse proxy or load balancer. Treat the built-in TLS as a development convenience only: ```toml [server] sslCertFile = "/path/cert.pem" sslKeyFile = "/path/key.pem" ``` ## 2. Native login (OIDC) Recent Streamlit releases include `st.login()`, `st.logout()`, and `st.user` for OpenID Connect authentication against Google, Microsoft Entra ID, Okta, or any OIDC provider. Configuration lives in `.streamlit/secrets.toml`: ```toml [auth] redirect_uri = "https://app.example.com/oauth2callback" cookie_secret = "REPLACE_WITH_LONG_RANDOM_STRING" client_id = "" client_secret = "" server_metadata_url = "https://accounts.google.com/.well-known/openid-configuration" ``` Gate the app at the top of the script, then authorise. `st.login()` on its own accepts any account the provider will authenticate (with Google, any Google account), so check who logged in before showing anything: ```python import streamlit as st ALLOWED_DOMAIN = "example.com" if not st.user.is_logged_in: st.login() st.stop() if st.user.get("hd") != ALLOWED_DOMAIN: st.error("This account is not authorised for this app.") st.stop() if not st.user.get("email_verified"): st.error("This account's email is not verified.") st.stop() st.write(f"Hello, {st.user.name}") ``` Streamlit copies the ID token claims onto `st.user`, readable via `st.user.get(...)` or `st.user["..."]`. The `hd` (hosted domain) claim is the trusted Workspace-domain check (matching [oidc-integration.md](oidc-integration.md)): Google sets it only for Workspace and Cloud-organization accounts, and it is absent for consumer gmail.com accounts. For a small fixed user set, an explicit allowlist of addresses is the alternative. Allowlist rules and claim checks are in [oidc-integration.md](oidc-integration.md). Notes from the Streamlit docs: this is authentication only (identity, not per-resource authorization), the identity cookie lasts 30 days and that period is not configurable, and `secrets.toml` holds the client secret, so it must never be committed. Confirm that your installed Streamlit version includes these functions; they are absent from older releases. MFA: `st.login()` delegates authentication to the OIDC provider, so enforce MFA there (Google, Microsoft Entra ID, Okta, Keycloak, and authentik all support it). Without OIDC, front the app per section 3. Options in [mfa.md](mfa.md). ## 3. Alternatives when OIDC is not available - Basic auth at a reverse proxy in front of a loopback-bound app ([nginx.md](nginx.md), [caddy.md](caddy.md)). - Cloudflare Access in front of a tunnel ([cloudflare.md](cloudflare.md)), which adds SSO or one-time-PIN login without touching the app. A password typed into a plain `st.text_input` and compared in the script is not authentication; it ships no session management, no hashing, and no rate limiting. ## 4. Verify ```bash ss -tlnp | grep 8501 # 127.0.0.1 when behind a proxy curl -sI https://app.example.com/ # succeeds over TLS # In a private browser window: the IdP login (or proxy auth) appears before the app. ``` ## Sources (checked September 2026) - config.toml reference (server.address, server.sslCertFile, server.sslKeyFile, and the production warning): https://docs.streamlit.io/develop/api-reference/configuration/config.toml - Authentication concepts (st.login, st.logout, st.user, [auth] keys, default scope, stated limitations): https://docs.streamlit.io/develop/concepts/connections/authentication - st.user API reference (claims copied from the ID token, `st.user.email`): https://docs.streamlit.io/develop/api-reference/user/st.user ====================================================================== ==> n8n.md ====================================================================== # n8n: binding, TLS, and MFA n8n includes user management (complete the owner setup on first run), but its network defaults deserve attention: `N8N_LISTEN_ADDRESS` defaults to `::`, which listens on **all interfaces**, on port `5678` over plain HTTP. ## 1. Bind privately Behind a reverse proxy or tunnel (the recommended layout): ``` N8N_LISTEN_ADDRESS=127.0.0.1 N8N_PORT=5678 N8N_HOST=n8n.example.com ``` Publish only the proxy per [caddy.md](caddy.md)/[nginx.md](nginx.md) with a certificate from [free-certificates.md](free-certificates.md), or use [cloudflare.md](cloudflare.md)/[tailscale.md](tailscale.md). Webhook endpoints are meant to be reachable by external services; that is no reason for the editor UI to be. ## 2. Or terminate TLS in n8n itself ``` N8N_PROTOCOL=https # default is http N8N_SSL_KEY=/path/to/privkey.pem N8N_SSL_CERT=/path/to/fullchain.pem ``` ## 3. Accounts and MFA - Finish the owner-account setup immediately after first start; an unclaimed n8n instance is open to whoever reaches it first. - Individual users can enable two-factor authentication on their accounts (verify availability for your version and licence). - Instance-wide enforcement exists under **Settings > Security** ("Enforce two-factor authentication"), or via `N8N_MFA_ENFORCED_ENABLED=true` with `N8N_SECURITY_POLICY_MANAGED_BY_ENV=true`; per the n8n docs this enforcement requires a Business or Enterprise licence on self-hosted instances, and it does not apply to SSO logins (enforce MFA at the identity provider for those; [mfa.md](mfa.md)). - Credentials stored in n8n (API keys for the services your workflows touch) make the instance a secrets vault; treat access to it accordingly ([secrets.md](secrets.md)). ## 4. Verify ```bash ss -tlnp | grep 5678 # 127.0.0.1, not :: or 0.0.0.0 curl -sI https://n8n.example.com/ # TLS, and a login page rather than the editor ``` ## Sources (checked September 2026) - n8n deployment environment variables (N8N_LISTEN_ADDRESS, N8N_PROTOCOL, N8N_SSL_KEY, N8N_SSL_CERT, defaults): https://docs.n8n.io/deploy/host-n8n/configure-n8n/basic-configuration/use-environment-variables/deployment.md - n8n security policies (MFA enforcement, licensing, SSO exception): https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/manage-security-policies.md - n8n SSL setup: https://docs.n8n.io/deploy/host-n8n/configure-n8n/security/set-up-ssl.md ====================================================================== ==> code-server.md ====================================================================== # code-server: browser VS Code without giving away the machine code-server runs a terminal in the browser, so exposure equals remote code execution. Its own documentation is blunt: never expose it directly to the internet without authentication and encryption. ## 1. Prefer no exposure at all The code-server docs recommend SSH port forwarding first, which needs no additional setup: ```bash ssh -L 8080:127.0.0.1:8080 user@host # then open http://localhost:8080 locally ``` [tailscale.md](tailscale.md) (serve, tailnet-only) and [cloudflare.md](cloudflare.md) (tunnel plus Access with MFA) are the equivalents when SSH is unavailable. ## 2. If it must be reachable: config.yaml `~/.config/code-server/config.yaml`: ```yaml bind-addr: 127.0.0.1:8080 # keep loopback behind a proxy or tunnel auth: password # default; the generated password lives in this file cert: /path/to/fullchain.pem # only when code-server terminates TLS itself cert-key: /path/to/privkey.pem ``` Password attempts are rate-limited (2 per minute plus 12 per hour). Replace the generated password with your own long random value, and treat the config file as a secret ([secrets.md](secrets.md)). For public access, the docs' supported pattern is a reverse proxy with a real certificate: [caddy.md](caddy.md) or [nginx.md](nginx.md) with [free-certificates.md](free-certificates.md), with MFA added at that layer ([mfa.md](mfa.md)) since the built-in login is a single factor. ## 3. Verify ```bash ss -tlnp | grep 8080 # loopback only curl -sI https://code.example.com/ # TLS, login page, never the editor ``` An unauthenticated editor in a private browser window means whoever finds the URL owns the host. ## Sources (checked September 2026) - code-server deployment guide (config.yaml keys, password auth and rate limits, exposure recommendations): https://coder.com/docs/code-server/guide - code-server repository: https://github.com/coder/code-server ====================================================================== ==> vector-databases.md ====================================================================== # Vector databases: Qdrant, Weaviate, Milvus, Chroma, pgvector A RAG store holds every document the application was given, often including private data, and it answers similarity queries that reconstruct that text. Several of these servers ship with no authentication enabled and none of them serves TLS out of the box, so an exposed default install is a searchable copy of your corpus. Keep the store on a private interface, turn on the native key or account control where one exists, and put TLS in front or on the server before any client crosses a network. ## 1. Bind privately Each server listens on plain TCP; publish only a reverse proxy ([nginx.md](nginx.md), [caddy.md](caddy.md)), a tunnel ([cloudflare.md](cloudflare.md)), or a tailnet ([tailscale.md](tailscale.md)). In Docker, map to loopback (`-p 127.0.0.1:6333:6333`), not `-p 6333:6333`, which binds every interface ([docker.md](docker.md)). Default ports, from the vendor pages in Sources: | Server | Default ports | |---|---| | Qdrant | 6333 (REST), 6334 (gRPC), 6335 (internal cluster gRPC) | | Weaviate | 8080 (HTTP), 50051 (gRPC) | | Milvus | 19530 (gRPC), 9091 (WebUI) | | Chroma | 8000 | | pgvector | 5432 (it is PostgreSQL) | ## 2. Qdrant: API key and TLS Per the Qdrant security page, "all self-deployed Qdrant instances are not secure" by default and connections are unencrypted. Set an API key in the config file or through the environment, and add a read-only key for query-only clients: ```yaml service: api_key: REPLACE_WITH_LONG_RANDOM_VALUE read_only_api_key: REPLACE_WITH_ANOTHER_LONG_RANDOM_VALUE enable_tls: true tls: cert: ./tls/cert.pem key: ./tls/key.pem ``` Environment equivalents: `QDRANT__SERVICE__API_KEY` and `QDRANT__SERVICE__READ_ONLY_API_KEY`. Clients send the key in the `api-key` header (or `Authorization: Bearer`). Qdrant's own docs say that enabling the key without TLS is insecure; terminate TLS either in Qdrant as above or at a proxy in front. ## 3. Weaviate: disable anonymous access, then authorize `AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED` defaults to `true`, which the Weaviate docs describe as strongly discouraged outside development. The secured Docker example: ```yaml environment: AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED: 'false' AUTHENTICATION_APIKEY_ENABLED: 'true' AUTHENTICATION_APIKEY_ALLOWED_KEYS: 'REPLACE_WITH_ADMIN_KEY,REPLACE_WITH_APP_KEY' AUTHENTICATION_APIKEY_USERS: 'admin-user,app-user' AUTHORIZATION_RBAC_ENABLED: 'true' AUTHORIZATION_RBAC_ROOT_USERS: 'admin-user' ``` Keys map to users by position, so the first key belongs to `admin-user` and the second to `app-user`. Only `admin-user` is a root user with full access; give `app-user` a custom role limited to its collections, created with the admin key through the RBAC API as the [RBAC configuration page](https://docs.weaviate.io/deploy/configuration/configuring-rbac) describes, and keep the admin key off the application host. Authentication alone lets any key holder do anything; add authorization with either RBAC (above, generally available from v1.29 per the authorization page) or the simpler admin list (`AUTHORIZATION_ADMINLIST_ENABLED`, `AUTHORIZATION_ADMINLIST_USERS`, `AUTHORIZATION_ADMINLIST_READONLY_USERS`; it cannot be combined with RBAC). For human logins, `AUTHENTICATION_OIDC_ENABLED` with `AUTHENTICATION_OIDC_ISSUER` and `AUTHENTICATION_OIDC_CLIENT_ID` delegates to an identity provider, where MFA is enforced ([mfa.md](mfa.md)). The documented deployment starts Weaviate with `--scheme http` and points to a reverse proxy for domain access, forwarding both 8080 and 50051; give the proxy the certificate ([free-certificates.md](free-certificates.md)) and do not expose the plain ports. ## 4. Milvus: enable authentication, change root, add TLS Authentication is enabled by setting `common.security.authorizationEnabled: true` in `milvus.yaml` (or through the Helm `extraConfigFiles` / Operator `spec.config` equivalents). Once on, the built-in `root` user exists with the password `Milvus`; change it before exposure, since a documented default is a public credential ([authentication.md](authentication.md)): ```python client = MilvusClient(uri="https://milvus.example.com:19530", token="root:Milvus") client.update_password(user_name="root", old_password="Milvus", new_password="REPLACE_WITH_LONG_RANDOM_VALUE") ``` Create a per-application user rather than handing `root` to the app. TLS is configured in the same file, with certificates mounted into the container (Docker Compose: a volume such as `./tls:/milvus/tls`): ```yaml tls: serverPemPath: /milvus/tls/server.pem serverKeyPath: /milvus/tls/server.key caPemPath: /milvus/tls/ca.pem common: security: tlsMode: 1 # 1 = server certificate only; 2 = mutual TLS, clients present a certificate too ``` Clients then connect with `secure=True` and the server certificate path. TLS and authentication are independent in Milvus; enable both. ## 5. Chroma: no native authentication since 1.0 Chroma's migration notes for v1.0.0 state that "Chroma no longer provides built-in authentication implementations". A self-hosted `chroma run --path /db_path` server (port 8000) therefore accepts every request, and the older `CHROMA_SERVER_AUTHN_PROVIDER` / `CHROMA_SERVER_AUTHN_CREDENTIALS` variables from the 2024 auth overhaul no longer do anything; do not paste them from old tutorials and assume protection. Keep Chroma on loopback and expose it only through an authenticated TLS proxy (bearer-token or basic-auth block per [nginx.md](nginx.md) / [caddy.md](caddy.md)), a Cloudflare Tunnel with Access ([cloudflare.md](cloudflare.md)), or a tailnet ([tailscale.md](tailscale.md)). ## 6. pgvector: it is PostgreSQL pgvector is an extension (`CREATE EXTENSION vector;`, PostgreSQL 13 and later), so [postgresql.md](postgresql.md) applies unchanged: `ssl = on`, `hostssl` lines with `scram-sha-256`, a least-privilege role per application, and `sslmode=verify-full` in every connection string. When several tenants share one embeddings table, add row-level security keyed on the tenant column so a query can only match rows the connected role may see. ## 7. Hosted services and MFA Pinecone, Qdrant Cloud, Weaviate Cloud, and Zilliz authenticate with API keys: those are secrets under [secrets.md](secrets.md), one per environment, never committed, rotated on leak. The vendor terminates TLS, so the client-side check is that the SDK is pointed at the `https://` endpoint the console gives you. None of the self-hosted servers has a human login with a second factor; MFA exists only on the vendor console for the hosted tiers, at the identity provider when Weaviate uses OIDC, or on the fronting layer (Access policy, Authelia-style portal) for everything else ([mfa.md](mfa.md)). ## Verify ```bash ss -tlnp | grep -E '6333|6334|8080|50051|19530|9091|8000|5432' # 127.0.0.1 only curl -si https://qdrant.example.com/collections | head -1 # 401 or 403 without a key curl -s -H 'api-key: REPLACE_WITH_LONG_RANDOM_VALUE' https://qdrant.example.com/collections # collection list curl -si https://weaviate.example.com/v1/schema | head -1 # 401 without a key curl -si https://chroma.example.com/ | head -1 # 401 from the proxy, never a Chroma response ``` For Milvus, a `MilvusClient(uri=...)` call with no `token` must fail once `authorizationEnabled` is on, and the same call with the application user's credentials must succeed. ## Sources (checked September 2026) - Qdrant security (API key, read-only key, `api-key` header, TLS keys, ports, default insecurity): https://qdrant.tech/documentation/security/ - Weaviate authentication (anonymous access, API key, OIDC variables): https://docs.weaviate.io/deploy/configuration/authentication - Weaviate authorization (admin list, RBAC availability): https://docs.weaviate.io/deploy/configuration/authorization and RBAC configuration (`AUTHORIZATION_RBAC_ENABLED`, `AUTHORIZATION_RBAC_ROOT_USERS`): https://docs.weaviate.io/deploy/configuration/configuring-rbac - Weaviate environment variables (`AUTHENTICATION_ANONYMOUS_ACCESS_ENABLED` default, `GRPC_PORT` default): https://docs.weaviate.io/deploy/configuration/env-vars - Weaviate Docker installation (ports 8080/50051, `--scheme http`, reverse proxy layout): https://docs.weaviate.io/deploy/installation-guides/docker-installation - Milvus authentication (`common.security.authorizationEnabled`, default `root`/`Milvus`, `update_password`): https://milvus.io/docs/authenticate.md - Milvus TLS (`tls.*` paths, `common.security.tlsMode`, RESTful port note): https://milvus.io/docs/tls.md ; standalone install (ports 19530 and 9091): https://milvus.io/docs/install_standalone-docker.md - Chroma migration notes (v1.0.0 removal of built-in authentication; 2024 auth overhaul variables): https://docs.trychroma.com/docs/overview/migration ; client-server mode (`chroma run --path`, port 8000): https://docs.trychroma.com/docs/run-chroma/client-server - pgvector (`CREATE EXTENSION vector`, PostgreSQL 13 and later): https://github.com/pgvector/pgvector ====================================================================== ==> mcp-servers.md ====================================================================== # MCP servers: exposing Model Context Protocol servers safely An MCP server gives a model tools, and every tool it exposes runs with the credentials the server holds. The 2025-11-25 specification defines two transports. Over **stdio** the client launches the server as a subprocess and talks over stdin and stdout: there is no network listener, but the process runs as the launching user with every credential in its environment, so a malicious or careless server is a local compromise, not a network one. Over **Streamable HTTP** the server is a web service with a single endpoint accepting POST and GET, and every rule in [authentication.md](authentication.md) applies to it. The older HTTP with SSE transport (protocol version 2024-11-05) is deprecated; Streamable HTTP replaces it. The spec makes authorization OPTIONAL at the protocol level; this repository does not, so an HTTP MCP server reachable beyond loopback authenticates every request, either natively or through a fronting layer. ## 1. Prefer stdio, and keep HTTP on loopback - For a single-user tool on a workstation, use the stdio transport. The spec's own guidance for local servers is to "use the `stdio` transport to limit access to just the MCP client" and, if HTTP is used anyway, to require an authorization token or use a Unix domain socket with restricted access. - Give a stdio server only the environment it needs: the API key for the one service it wraps, not a shell profile full of cloud credentials. Per the spec, stdio servers "retrieve credentials from the environment", which means that environment is the server's whole privilege set ([secrets.md](secrets.md)). - For Streamable HTTP, the spec's Security Warning says: "When running locally, servers SHOULD bind only to localhost (127.0.0.1) rather than all network interfaces (0.0.0.0)". Bind to `127.0.0.1` and publish only a fronting layer, exactly as [ollama.md](ollama.md) does for a model server. Check the SDK or framework you use for its bind-address option; do not assume its default is loopback. ## 2. Validate `Origin` (DNS rebinding) The spec requires it: "Servers MUST validate the `Origin` header on all incoming connections to prevent DNS rebinding attacks. If the `Origin` header is present and invalid, servers MUST respond with HTTP 403 Forbidden." Without this, "attackers could use DNS rebinding to interact with local MCP servers from remote websites": a page in the user's browser resolves its own hostname to `127.0.0.1` and drives the local server. Do the check in the server (most SDKs have an allowed-origins option; confirm yours is on). A reverse proxy can add a second check in front, using the nginx `if` directive with the `$http_origin` variable: ```nginx location /mcp { if ($http_origin !~ "^https://mcp\.example\.com$") { return 403; } proxy_pass http://127.0.0.1:3000; } ``` Non-browser clients often send no `Origin` at all; the spec's requirement is about a present and invalid header, so decide deliberately whether a missing header is accepted (the nginx test above rejects it) and document the choice. To accept requests that carry no `Origin` while still rejecting a wrong one, use a `map` in the `http` block (an empty string key matches a missing header) and test the variable in the location: ```nginx map $http_origin $bad_origin { default 1; "" 0; # no Origin header: a non-browser MCP client "https://mcp.example.com" 0; } ``` ```nginx location /mcp { if ($bad_origin) { return 403; } proxy_pass http://127.0.0.1:3000; } ``` ## 3. TLS The MCP server itself speaks plain HTTP on loopback. Terminate TLS at the proxy per [nginx.md](nginx.md) or [caddy.md](caddy.md) with a certificate from [free-certificates.md](free-certificates.md), or publish through [cloudflare.md](cloudflare.md) or [tailscale.md](tailscale.md). Bearer tokens cross the network with every request, so the spec requires HTTPS for all authorization server endpoints, and [authentication.md](authentication.md) requires it for every credential. ## 4. Authentication: native OAuth 2.1 or a fronting layer **Option A, spec authorization.** The MCP server acts as an OAuth 2.1 resource server. The spec's requirements, in its own terms: - MCP servers "MUST implement OAuth 2.0 Protected Resource Metadata (RFC9728)", and the metadata "MUST include the `authorization_servers` field". Discovery is either a `WWW-Authenticate` header carrying `resource_metadata` on `401 Unauthorized` responses, or the well-known URI (`/.well-known/oauth-protected-resource`, optionally suffixed with the endpoint path); clients must support both. - Clients "MUST implement PKCE" with the `S256` method and "MUST refuse to proceed" if the authorization server's metadata lacks `code_challenge_methods_supported`. Clients MUST send the RFC 8707 `resource` parameter naming the MCP server's canonical URI. - Servers "MUST validate that access tokens were issued specifically for them as the intended audience"; invalid or expired tokens get `401`, insufficient scope gets `403`. Servers "MUST NOT accept or transit any other tokens": the token the client presents never travels on to an upstream API. If the server calls upstream APIs, "the access token used at the upstream API is a separate token, issued by the upstream authorization server". - Tokens go in `Authorization: Bearer ...` on every request, never in the URL. Sessions are not authentication: servers "MUST NOT use sessions for authentication", and the `MCP-Session-Id` must be a secure, non-deterministic value. Pick an identity provider from [identity-providers.md](identity-providers.md) as the authorization server; the client-side hygiene in [oidc-integration.md](oidc-integration.md) applies. Azure App Service can serve the RFC 9728 document for an app behind its built-in authentication: set the `WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES` application setting to the required scopes, and the 401 challenge then carries the metadata URL and scopes. Microsoft marks this **preview** at the time of writing and says the configuration may change ([cloud-identity-proxies.md](cloud-identity-proxies.md) covers the rest of Easy Auth). **Option B, a fronting layer.** Keep the server on loopback and authenticate at the edge: a reverse proxy bearer-token check (the nginx `if ($http_authorization ...)` block in [ollama.md](ollama.md)) or basic auth per [nginx.md](nginx.md)/[caddy.md](caddy.md); Cloudflare Access with a service token for machine clients per [cloudflare.md](cloudflare.md); or an identity-aware proxy per [cloud-identity-proxies.md](cloud-identity-proxies.md). Generate and rotate the token per [machine-auth.md](machine-auth.md). Be clear about what Option B is. A static bearer token at the proxy is a fronting control, not MCP authorization: it serves no Protected Resource Metadata and no `WWW-Authenticate` challenge with `resource_metadata`, so an MCP client that expects the OAuth discovery flow will not negotiate it. Such clients need the token pre-configured (most clients accept static headers for a server), or Option A. MFA: MCP has no login dialogue of its own. Under Option A, MFA is whatever the authorization server enforces; under Option B it comes from the identity provider behind Access or the identity-aware proxy. Enforce it there per [mfa.md](mfa.md). ## 5. The credentials the server holds - Each tool's upstream API key is a secret the server holds on behalf of every caller. Load it from the environment or a secret manager, one key per server and environment, never from the repository ([secrets.md](secrets.md)). - Least privilege per tool: a read-only tool gets a read-only key. A server that wraps a database, a cloud account, and a mail API with admin credentials turns every prompt injection into an administrator. - Never forward the client's token upstream (spec: "Token passthrough" is an anti-pattern and "explicitly forbidden"). Log tool calls with the authenticated identity so an incident can be traced. ## Verify ```bash ss -tlnp | grep 3000 # 127.0.0.1:3000 only, never 0.0.0.0 or :: # From another host: refused at the origin, or answered only by the fronting layer. curl -s http://203.0.113.10:3000/mcp # connection refused # Unauthenticated initialize: 401 with a WWW-Authenticate header (Option A) or the proxy's 401 (Option B). curl -si -X POST https://mcp.example.com/mcp \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' \ -d '{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2025-11-25","capabilities":{},"clientInfo":{"name":"check","version":"1.0.0"}}}' # Option A only: the metadata the 401 points at exists and names your authorization server. curl -s https://mcp.example.com/.well-known/oauth-protected-resource # JSON with "authorization_servers" # Wrong Origin, with a valid credential: 403. curl -si -X POST https://mcp.example.com/mcp -H 'Origin: https://attacker.example' \ -H 'Authorization: Bearer REPLACE_WITH_LONG_RANDOM_VALUE' \ -H 'Content-Type: application/json' -H 'Accept: application/json, text/event-stream' -d '{}' ``` A token issued for a different resource (wrong audience) must also fail with `401`; test it under Option A. ## Common mistakes - Binding the HTTP transport to `0.0.0.0` "for Docker" and leaving it there. Publish the container port on `127.0.0.1` only ([docker.md](docker.md)). - Treating stdio as safe because it has no port: the server runs as you, with your environment. - Accepting any token from your identity provider without checking the audience, so a token issued for another API also opens the MCP server. - Passing the caller's token to the upstream API, which makes the upstream trust a token it never issued and loses the audit trail. ## Sources (checked September 2026) - MCP specification 2025-11-25, Transports (stdio, Streamable HTTP, Security Warning, deprecated HTTP+SSE, `MCP-Session-Id`): https://modelcontextprotocol.io/specification/2025-11-25/basic/transports - MCP specification 2025-11-25, Authorization (OPTIONAL, RFC 9728, PKCE, `resource`, audience validation, error codes): https://modelcontextprotocol.io/specification/2025-11-25/basic/authorization - MCP specification 2025-11-25, Security Best Practices (token passthrough, session hijacking, local server compromise): https://modelcontextprotocol.io/specification/2025-11-25/basic/security_best_practices - MCP specification 2025-11-25, Lifecycle (`initialize` request shape): https://modelcontextprotocol.io/specification/2025-11-25/basic/lifecycle - Azure App Service authentication (protected resource metadata preview, `WEBSITE_AUTH_PRM_DEFAULT_WITH_SCOPES`): https://learn.microsoft.com/en-us/azure/app-service/overview-authentication-authorization - nginx `if` and `return` directives: https://nginx.org/en/docs/http/ngx_http_rewrite_module.html - nginx embedded variables (`$http_name`): https://nginx.org/en/docs/http/ngx_http_core_module.html - nginx ngx_http_map_module (Origin allowlist map): https://nginx.org/en/docs/http/ngx_http_map_module.html ====================================================================== ==> ray.md ====================================================================== # Ray: dashboard, Jobs, and Client ports execute code Ray's own security page is blunt: if you expose the Ray Dashboard, Ray Jobs, or Ray Client services, "anybody who can access the associated ports can execute arbitrary code on your Ray Cluster", explicitly by submitting a Job or connecting a Client, indirectly through the Dashboard REST API, and implicitly because Ray deserialises arbitrary Python objects with cloudpickle. Ray "doesn't implement access controls for developers interacting with a given cluster"; security and isolation "must be enforced outside of the Ray Cluster". The ports in question are the dashboard (and Jobs API) on `8265`, the Ray Client server on `10001`, and the head node port `6379`, all plain HTTP or gRPC with no login of their own. ## 1. Keep the dashboard on loopback `ray start --dashboard-host` defaults to localhost, and `--dashboard-port` defaults to `8265`. Leave both alone and say so explicitly, because most tutorials and container entrypoints override the host to make the UI reachable: ```bash ray start --head --dashboard-host 127.0.0.1 --dashboard-port 8265 ``` Never pass `--dashboard-host 0.0.0.0` (or `::`) on a machine with a public interface. In Docker the two binds are different things: the host publishes on loopback only (`-p 127.0.0.1:8265:8265`, which Docker's port-publishing docs say only the Docker host can reach, assuming a normal bridge-network NAT setup and a Docker version at or after 28.0.0; earlier releases could let hosts on the same layer-2 network reach a loopback-published port), while inside the container the dashboard must listen on the container's own interface (`--dashboard-host 0.0.0.0` there, private to the Compose network and the host), because a dashboard bound to the container's `127.0.0.1` is not reachable through the published port at all. See [docker.md](docker.md). Reach the dashboard and the Jobs API through a channel that already authenticates you: ```bash ssh -L 8265:127.0.0.1:8265 user@203.0.113.10 # then open http://127.0.0.1:8265 ray job submit --address http://127.0.0.1:8265 -- python script.py ray dashboard cluster.yaml # cluster launcher: sets up the same SSH forwarding kubectl port-forward svc/"$HEAD_SERVICE" 8265:8265 # KubeRay: the RayCluster head service ``` A tailnet ([tailscale.md](tailscale.md)) is the other clean option: the dashboard binds to the tailnet address, and only enrolled devices can route to it. If a browser-facing hostname is unavoidable, put an authenticating TLS proxy in front ([nginx.md](nginx.md), [caddy.md](caddy.md), or [cloudflare.md](cloudflare.md) with Access) and keep the origin on loopback; Ray's docs themselves list "deploy a TLS proxy in front of your Ray cluster" as the pattern. ## 2. Network isolation is the primary boundary Ray expects "a controlled, isolated network" between all its components. Beyond `8265`, the head node listens on `6379` (head process), `10001` (Ray Client), and every node opens worker ports `10002` to `19999` by default plus several randomised ports. Put every node of a cluster in one private network or security group that admits only the cluster's own members ([cloud-firewalls.md](cloud-firewalls.md), [host.md](host.md), [kubernetes.md](kubernetes.md)), and expose nothing from that group to the internet. The Ray Client port in particular is a remote code execution endpoint by design; use Ray Jobs over the forwarded dashboard port instead of publishing `10001`. Ray does not isolate jobs from each other. Workloads that must not see each other's data or credentials go on separate clusters. ## 3. Token authentication (Ray 2.52.0 and later) Starting in Ray 2.52.0 the cluster can require a shared-secret token on every external API and internal connection. Per the Ray docs it is disabled by default in 2.52.0 (as of September 2026, with a plan to enable it by default in a future release) and is "not an alternative to deploying Ray clusters in a controlled network environment", only defence in depth. ```bash export RAY_AUTH_MODE=token ray get-auth-token --generate # writes ~/.ray/auth_token and prints it RAY_AUTH_MODE=token ray start --head ``` Every node and every client needs the same token. Ray reads it from `RAY_AUTH_TOKEN`, then from the file named by `RAY_AUTH_TOKEN_PATH`, then from `~/.ray/auth_token`; the docs recommend the file paths over the environment variable so other code that reads the environment cannot see it. Copy the file to each node before `ray start`, keep its permissions tight, and never commit it: tokens do not expire and are stored in plaintext ([secrets.md](secrets.md)). The token travels as an HTTP header, so over plain HTTP it is visible to the network; only send it inside the SSH tunnel, tailnet, or TLS proxy from step 1. On Kubernetes, KubeRay v1.6.0 and later enable this through the `authOptions` field of a `RayCluster`; the operator creates a Secret with a random token and sets `RAY_AUTH_MODE` and `RAY_AUTH_TOKEN` on every Ray container. Clients read it with `kubectl get secrets --template={{.data.auth_token}} | base64 -d`. Without the token, `ray job submit` fails with `401 Unauthorized`. MFA: Ray has no user accounts, so a second factor can only come from the path to the cluster: the SSH login, the tailnet, or an identity-aware proxy in front of the dashboard ([mfa.md](mfa.md)). ## 4. TLS for the gRPC traffic Ray can encrypt and mutually authenticate its internal gRPC connections. Export these in the environment of every node, head and workers alike, before Ray starts there; Ray reads them at startup (`RAY_USE_TLS` defaults to `0`), so a plain assignment without `export`, or a variable set after the node started, never reaches the Ray processes: ```bash export RAY_USE_TLS=1 # default 0 export RAY_TLS_SERVER_CERT=/etc/ray/tls/tls.crt # presented to other endpoints export RAY_TLS_SERVER_KEY=/etc/ray/tls/tls.key export RAY_TLS_CA_CERT=/etc/ray/tls/ca.crt # CA that signs every node's certificate ``` Ray warns that this costs performance (large for small workloads, smaller for large ones) and that it "is not a replacement for network isolation". The docs describe it for the gRPC traffic; for the dashboard and Jobs HTTP API they point to a TLS proxy in front, which is the fronting proxy in step 1. ## Verify ```bash ss -tlnp | grep 8265 # 127.0.0.1:8265 (or the tailnet IP), never 0.0.0.0 or * ss -tlnp | grep -E ':6379|:10001' # private interface only curl -sI --max-time 5 http://203.0.113.10:8265/ # from another network: connection refused or timeout # Through the SSH tunnel of step 1, from a machine without the token, with RAY_AUTH_MODE=token on the cluster: ray job submit --address http://127.0.0.1:8265 -- python -c "print(1)" # must fail: Unauthorized ``` ## Common mistakes - `--dashboard-host 0.0.0.0` copied from a quickstart so the UI "works" from a laptop; forward the port instead. - Treating token authentication as permission to expose `8265` to the internet. Ray says the opposite. - Publishing `10001` for Ray Client convenience. It is unauthenticated code execution unless the token and isolation above are both in place. - Putting the head node in a security group that also hosts unrelated services; any compromise there reaches every Ray worker. ## Sources (checked September 2026) - Ray security guidelines (arbitrary code execution, network isolation, TLS is not a replacement, token auth from 2.52.0): https://docs.ray.io/en/latest/ray-security/index.html - Ray token authentication (`RAY_AUTH_MODE`, `RAY_AUTH_TOKEN`, `RAY_AUTH_TOKEN_PATH`, `ray get-auth-token`, plaintext-header caveat): https://docs.ray.io/en/latest/ray-security/token-auth.html - `ray start` CLI reference (`--dashboard-host` default, `--dashboard-port` 8265, `--port` 6379, `--ray-client-server-port` 10001): https://docs.ray.io/en/latest/cluster/cli.html - Configuring Ray (TLS environment variables, ports opened by nodes): https://docs.ray.io/en/latest/ray-core/configure.html - Configure Ray clusters to use token authentication (KubeRay `authOptions`, 401 without token): https://docs.ray.io/en/latest/cluster/kubernetes/user-guides/kuberay-auth.html - Docker, port publishing (loopback publishing): https://docs.docker.com/engine/network/port-publishing/ ====================================================================== ==> mlflow.md ====================================================================== # MLflow tracking server: no authentication by default `mlflow server` serves the tracking UI and REST API at `http://127.0.0.1:5000` using the default application `mlflow.server:app`, which performs no authentication: anyone who can reach the port can read, alter, and delete experiments, runs, registered models, and (with artifact proxying on) the artifacts themselves. Authentication is opt-in through a separate app, and the server has no TLS option of its own; MLflow's tracking-server documentation recommends a reverse proxy or VPN for both. ## 1. Bind privately The defaults are already loopback: ```bash mlflow server --host 127.0.0.1 --port 5000 ``` The CLI help for `--host` says it plainly: "This is NOT a security setting". Do not switch to `--host 0.0.0.0` on a machine with a public interface. In Docker the two binds are different things: the host publishes on loopback (`-p 127.0.0.1:5000:5000`, which Docker's port-publishing docs say only the Docker host can reach, assuming a normal bridge-network NAT setup and a Docker version at or after 28.0.0; earlier releases could let hosts on the same layer-2 network reach a loopback-published port), while inside the container the server must listen on the container's own interface (`--host 0.0.0.0` there, private to the Compose network and the host), because a process bound to the container's `127.0.0.1` is not reachable through the published port at all. See [docker.md](docker.md). If the server must listen beyond loopback for a proxy on another host, set `--allowed-hosts mlflow.example.com` (the default allows localhost and private ranges only) and `--cors-allowed-origins https://mlflow.example.com`, and never use `--disable-security-middleware` outside a test. ## 2. TLS from a fronting proxy `mlflow server` has no certificate flags. Terminate TLS in nginx or Caddy per [nginx.md](nginx.md)/[caddy.md](caddy.md) with `proxy_pass http://127.0.0.1:5000` or `reverse_proxy 127.0.0.1:5000`, a certificate from [free-certificates.md](free-certificates.md), and `--allowed-hosts` set to the public hostname; or use a tunnel or tailnet ([cloudflare.md](cloudflare.md), [tailscale.md](tailscale.md)). Point clients at `MLFLOW_TRACKING_URI=https://mlflow.example.com`, and never set `MLFLOW_TRACKING_INSECURE_TLS=true` in production (MLflow's own docs say the same). ## 3. Turn on the built-in basic auth MLflow ships an HTTP basic-auth app that stores users and per-resource permissions in a database (as of September 2026 the current documentation page carries no experimental label; verify before relying on it). The client-side `MLFLOW_TRACKING_USERNAME`/`MLFLOW_TRACKING_PASSWORD` variables do nothing on their own; the server must run this app. ```bash pip install 'mlflow[auth]' export MLFLOW_FLASK_SERVER_SECRET_KEY="REPLACE_WITH_LONG_RANDOM_VALUE" # CSRF key, required; same value on every replica MLFLOW_AUTH_CONFIG_PATH=/etc/mlflow/basic_auth.ini mlflow server --app-name basic-auth ``` The first start creates an admin user named `admin` with password `password1234`. Set your own admin credentials in the configuration file before that first start so the default password never exists, or change it immediately: ```ini # /etc/mlflow/basic_auth.ini [mlflow] # default is READ on every resource default_permission = NO_PERMISSIONS database_uri = postgresql://mlflow_auth:REPLACE_WITH_LONG_RANDOM_VALUE@127.0.0.1:5432/mlflow_auth admin_username = admin admin_password = REPLACE_WITH_LONG_RANDOM_VALUE ``` `database_uri` defaults to a SQLite file `basic_auth.db` in the working directory; MLflow recommends a central database for multi-node deployments. The same file can name an `authorization_function` (`module:function`) for a custom scheme, but the shipped one is basic auth. To rotate the admin password on a running server: ```bash curl -u admin:password1234 -X PATCH https://mlflow.example.com/api/2.0/mlflow/users/update-password \ -H 'Content-Type: application/json' \ -d '{"username":"admin","password":"REPLACE_WITH_LONG_RANDOM_VALUE"}' ``` Creating users requires admin credentials (UI at `/signup`, or `POST /api/2.0/mlflow/users/create`). Give humans individual accounts and CI its own low-permission user per [authentication.md](authentication.md); `~/.mlflow/credentials` stores passwords unencrypted, so prefer the environment variables injected at runtime. The documentation notes that the UI has no limit on login attempts, so rate-limit the login path at the proxy per [nginx.md](nginx.md); and because basic auth sends the password with every request ([authentication.md](authentication.md)), step 2 comes first. MFA: the basic-auth app has none. Put an identity-aware layer in front (Cloudflare Access, Authelia, oauth2-proxy per [mfa.md](mfa.md)); MLflow clients can pass a proxy bearer token via `MLFLOW_TRACKING_TOKEN`. ## 4. Artifact store credentials With `--serve-artifacts` (the default) and `--artifacts-destination s3://bucket`, the server proxies every artifact read and write, so it holds the storage credentials and clients need none. Supply them through the environment or an instance role, never in a compose file or the repository ([secrets.md](secrets.md), [object-storage.md](object-storage.md)). With `--no-serve-artifacts`, every client needs its own storage credentials and the tracking server's permissions no longer gate the artifacts. ## Verify ```bash ss -tlnp | grep 5000 # 127.0.0.1 only curl -sI --max-time 5 http://203.0.113.10:5000/ # from another machine: connection refused # experiments/search is a POST endpoint, so each check posts a minimal body curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{"max_results":1}' \ https://mlflow.example.com/api/2.0/mlflow/experiments/search # 401: no credentials curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{"max_results":1}' \ -u admin https://mlflow.example.com/api/2.0/mlflow/experiments/search # 200 with the new admin password curl -sS -o /dev/null -w '%{http_code}\n' -X POST -H 'Content-Type: application/json' -d '{"max_results":1}' \ -u admin:password1234 https://mlflow.example.com/api/2.0/mlflow/experiments/search # must be 401: default password gone ``` An authenticated user without permission on a resource gets `403`; a missing or wrong credential gets `401`. ## Common mistakes - Running `--host 0.0.0.0` "for the team" with no `--app-name basic-auth` and no proxy: the whole experiment history is world-writable. - Leaving `admin` / `password1234` in place after the first start. - Setting `MLFLOW_TRACKING_USERNAME` in CI and assuming the server checks it; without the auth app it is ignored. - Committing `basic_auth.ini` with `admin_password` or a database password inside it. ## Sources (checked September 2026) - MLflow authentication with username and password (`--app-name basic-auth`, default admin credentials, `basic_auth.ini` keys, `MLFLOW_AUTH_CONFIG_PATH`, `MLFLOW_FLASK_SERVER_SECRET_KEY`, client variables, 403 on missing permission): https://mlflow.org/docs/latest/self-hosting/security/basic-http-auth/ - MLflow authentication REST API (`2.0/mlflow/users/update-password` request fields): https://mlflow.org/docs/latest/api_reference/auth/rest-api.html - `mlflow server` CLI reference (`--host` default 127.0.0.1, `--port` 5000, `--app-name`, `--allowed-hosts`, `--cors-allowed-origins`, `--serve-artifacts`): https://mlflow.org/docs/latest/api_reference/cli.html - MLflow tracking server (default address, reverse proxy or VPN for TLS and auth, `MLFLOW_TRACKING_TOKEN`, `MLFLOW_TRACKING_INSECURE_TLS`, artifact proxying): https://mlflow.org/docs/latest/self-hosting/architecture/tracking-server - MLflow REST API, Search Experiments (`POST 2.0/mlflow/experiments/search`): https://mlflow.org/docs/latest/api_reference/rest-api.html - Docker, port publishing (loopback publishing): https://docs.docker.com/engine/network/port-publishing/ ====================================================================== ==> agent-builders.md ====================================================================== # Agent and workflow builders: Dify, Flowise, Langflow, LibreChat Each of these tools stores your provider API keys (OpenAI, Anthropic, and the rest) and exposes both an editor UI and callable APIs, so an open instance is a secrets vault plus free compute for whoever finds it. All four ship with login of some kind; the exposure comes from skipping the first-run setup, leaving default secrets in place, and publishing the container port on every interface over plain HTTP. None of them offers a native second factor that this guide can rely on, so MFA comes from an OIDC provider (where the tool supports OIDC) or from the fronting layer ([mfa.md](mfa.md)). ## 1. Bind privately Publish the container on loopback and let a proxy or tunnel be the only public listener ([docker.md](docker.md)): ```yaml ports: - "127.0.0.1:3000:3000" # Flowise (PORT defaults to 3000) - "127.0.0.1:7860:7860" # Langflow (LANGFLOW_PORT defaults to 7860) - "127.0.0.1:3080:3080" # LibreChat (PORT defaults to 3080) ``` Dify is different: its Compose file publishes nginx on `EXPOSE_NGINX_PORT=80` and `EXPOSE_NGINX_SSL_PORT=443` from `docker/.env`, plus the plugin daemon's `EXPOSE_PLUGIN_DEBUGGING_PORT=5003` (optional vector store profiles publish more). Do not hide a published port with the host firewall: Docker's NAT rules divert the traffic before it reaches the chains UFW uses, so a UFW deny on a published port does nothing ([docker.md](docker.md)). The plugin daemon's debugging port is only needed for remote plugin debugging, so leave it unpublished: do not enable the debugging feature, or remove that port mapping in a Compose override. Leave the backend services unpublished on the Compose network, and make Dify's nginx the only service with a public port: either as the TLS edge (section 2) or on loopback (`EXPOSE_NGINX_PORT=127.0.0.1:8080`) behind your own proxy. ## 2. TLS Flowise and LibreChat document no TLS of their own; their deployment guides put nginx with certbot in front (`proxy_pass http://localhost:3000` and `http://localhost:3080` respectively). Use [caddy.md](caddy.md) or [nginx.md](nginx.md) with a certificate from [free-certificates.md](free-certificates.md), or [cloudflare.md](cloudflare.md) / [tailscale.md](tailscale.md) with no public port at all. Set `NUMBER_OF_PROXIES` (Flowise) and `TRUST_PROXY` (LibreChat, default `1`) to the number of proxy hops so rate limiting sees client addresses. Langflow can terminate TLS itself with `LANGFLOW_SSL_CERT_FILE` and `LANGFLOW_SSL_KEY_FILE`; a fronting proxy remains the simpler place to add login and MFA. Dify's bundled nginx can terminate TLS. In `docker/.env`, per the certbot README in the Dify repository: set `NGINX_ENABLE_CERTBOT_CHALLENGE=true`, `CERTBOT_DOMAIN`, `CERTBOT_EMAIL`, `NGINX_SSL_CERT_FILENAME=fullchain.pem`, `NGINX_SSL_CERT_KEY_FILENAME=privkey.pem`; run `docker compose --profile certbot up --force-recreate -d` and `docker compose exec -it certbot /bin/sh /update-cert.sh`; then set `NGINX_HTTPS_ENABLED=true` (default `false`) and recreate nginx with `docker compose --profile certbot up -d --no-deps --force-recreate nginx`. `NGINX_SSL_PROTOCOLS` defaults to `TLSv1.2 TLSv1.3`. Set `CONSOLE_API_URL`, `CONSOLE_WEB_URL`, and `APP_WEB_URL` to the public `https://` URLs; per the Dify reference, `CONSOLE_API_URL` decides whether cookies are marked HTTPS-only. ## 3. Dify ```bash cd dify/docker && cp .env.example .env # edit .env now: INIT_PASSWORD, SECRET_KEY, the EXPOSE_* bindings (section 1), the public URLs (section 2) docker compose up -d ``` - `INIT_PASSWORD=REPLACE_WITH_LONG_RANDOM_VALUE` goes into `.env` before the first `up`. It is empty by default; when set, the `/install` page demands it before anyone can create the admin account. Once the stack is up, open `https://dify.example.com/install` yourself, immediately. - Set `SECRET_KEY` from `openssl rand -base64 42`. It signs session cookies and JWTs and encrypts stored OAuth credentials (left empty, Dify auto-generates one in its storage directory, per `.env.example`). - App API keys are created inside each app and sent as `Authorization: Bearer ` to the service API. They are not console accounts, so tightening console login does nothing for a leaked key. Dify's guidance: call the API from your backend only; a key in frontend code can be extracted. ## 4. Flowise - From v3.0.1 onwards Flowise uses email-and-password accounts with JWTs in HTTP-only cookies. `FLOWISE_USERNAME` / `FLOWISE_PASSWORD` are documented as deprecated; the docs use them only to migrate an older instance into a new admin account. Register the admin account before exposing the instance. - Set random values for `JWT_AUTH_TOKEN_SECRET`, `JWT_REFRESH_TOKEN_SECRET`, `EXPRESS_SESSION_SECRET` (default `flowise`), and `TOKEN_HASH_SECRET`; set `APP_URL` to the public URL (default `http://localhost:3000`). `FLOWISE_SECRETKEY_OVERWRITE` sets the key that encrypts stored credentials; without it the key lives in a file under `SECRETKEY_PATH`. - Prediction endpoints: a chatflow with no API key assigned is public to anyone who knows the chatflow ID. Create keys under **API Keys** (a `DefaultKey` is pre-created), assign one per chatflow, and clients send `Authorization: Bearer `; the prediction API answers `401` without it. ## 5. Langflow ``` LANGFLOW_AUTO_LOGIN=false LANGFLOW_SUPERUSER=REPLACE_WITH_ADMIN_USERNAME LANGFLOW_SUPERUSER_PASSWORD=REPLACE_WITH_LONG_RANDOM_VALUE LANGFLOW_SECRET_KEY=REPLACE_WITH_LONG_RANDOM_VALUE ``` - `LANGFLOW_AUTO_LOGIN` defaults to `True` in the application (the official Docker images set it to `false`), which means no login at all; set it to `false` explicitly. The password is then required and cannot be the legacy default `langflow`; the username defaults to `langflow`. - Generate the key with `python3 -c "from secrets import token_urlsafe; print(f'LANGFLOW_SECRET_KEY={token_urlsafe(32)}')"`. An auto-generated key is documented as unsuitable for production. - `LANGFLOW_NEW_USER_IS_ACTIVE` defaults to `False`: new accounts wait for the superuser to activate them. Keep it. - With auto-login off, API calls (`POST /api/v1/run/`) need a Langflow API key in the `x-api-key` header, created under **Settings > Langflow API Keys** or with `langflow api-key`. `LANGFLOW_SKIP_AUTH_AUTO_LOGIN` (default `false`) only applies when auto-login is on and is slated for removal; leave it alone. - `LANGFLOW_HOST` defaults to `localhost`; that is the right value behind a proxy on the same host. Version note: checked against the 1.12.x docs. ## 6. LibreChat - The first registered account becomes the admin. Register it, then set `ALLOW_REGISTRATION=false` so nobody else can create an email account. For SSO-only operation, also set `ALLOW_EMAIL_LOGIN=false` and enable `ALLOW_SOCIAL_REGISTRATION=true` deliberately, with the provider's allowlist deciding who may exist. - `ALLOW_SOCIAL_LOGIN=true` enables the OAuth2 providers (Apple, Discord, Facebook, GitHub, Google) and OIDC through `OPENID_ISSUER`, `OPENID_CLIENT_ID`, `OPENID_CLIENT_SECRET`, `OPENID_SESSION_SECRET`, `OPENID_SCOPE="openid profile email"`, `OPENID_CALLBACK_URL=/oauth/openid/callback`, optionally `OPENID_REQUIRED_ROLE`. The docs cover Keycloak, Authentik, Authelia, Auth0, Cognito, and Entra; with OIDC in place, set `ALLOW_EMAIL_LOGIN=false` and enforce MFA at the provider ([identity-providers.md](identity-providers.md), [oidc-integration.md](oidc-integration.md)). - `CREDS_KEY` is a 32-byte key (64 hexadecimal characters) and `CREDS_IV` a 16-byte IV (32 hexadecimal characters); `JWT_SECRET` and `JWT_REFRESH_SECRET` are unique random values of at least 32 bytes each. The docs point to the Credentials Generator. Never keep the `.env.example` values. - Set `DOMAIN_CLIENT` and `DOMAIN_SERVER` to the public `https://` URL. TLS comes from the fronting proxy (section 2). - The v0.7.7 changelog lists two-factor authentication with backup codes and QR enrolment, but the authentication documentation we checked does not describe it, so do not count on it as the enforced control; MFA at the OIDC provider is the documented path. ## 7. MFA and stored secrets None of the four documents instance-wide MFA enforcement. Where OIDC exists (LibreChat), enforce MFA at the provider; for Dify, Flowise, and Langflow put the editor behind Cloudflare Access or an identity layer per [mfa.md](mfa.md). Every provider key pasted into these tools is a secret held by the tool; rotate any key that lived on an instance that was ever open ([secrets.md](secrets.md)). ## Verify ```bash ss -tlnp | grep -E ':(3000|7860|3080|80|443) ' # app ports on 127.0.0.1; 80/443 public only where Dify's own nginx is the TLS edge curl -sI https://builder.example.com/ # TLS; login page or redirect, not the editor curl -s -o /dev/null -w '%{http_code}\n' -X POST 'https://flowise.example.com/api/v1/prediction/REPLACE_WITH_CHATFLOW_ID' # 401 curl -s -o /dev/null -w '%{http_code}\n' -X POST 'https://langflow.example.com/api/v1/run/REPLACE_WITH_FLOW_ID' # 401 curl -s -o /dev/null -w '%{http_code}\n' https://dify.example.com/v1/parameters # 401 ``` ## Common mistakes - Starting Dify without `INIT_PASSWORD` on a reachable host: the first visitor to `/install` owns the instance. - Running Langflow with the default `LANGFLOW_AUTO_LOGIN=True`, which is no login. - A Flowise chatflow with no API key assigned: the prediction API is public to anyone with the ID. - Leaving LibreChat registration open after the admin exists, or shipping the example `JWT_SECRET`. ## Sources (checked September 2026) - Dify Docker Compose deployment (setup at `/install`): https://docs.dify.ai/en/self-host/deploy/quick-start/docker-compose - Dify environment variables (`SECRET_KEY`, `INIT_PASSWORD`, `CONSOLE_API_URL`, `CONSOLE_WEB_URL`, `APP_WEB_URL`): https://docs.dify.ai/en/self-host/deploy/configuration/environments - Dify `docker/.env.example` (`EXPOSE_NGINX_PORT`, `NGINX_HTTPS_ENABLED`, certificate variables): https://github.com/langgenius/dify/blob/main/docker/.env.example ; `docker-compose.yaml` (which services publish ports): https://github.com/langgenius/dify/blob/main/docker/docker-compose.yaml - Docker packet filtering and firewalls (published ports bypass UFW): https://docs.docker.com/engine/network/packet-filtering-firewalls/ - Dify certbot README (HTTPS steps): https://github.com/langgenius/dify/blob/main/docker/certbot/README.md - Dify API keys (Bearer, backend-only): https://docs.dify.ai/en/api-reference/guides/get-started - Flowise app-level authentication (v3.0.1 accounts, deprecated username/password, JWT secrets): https://docs.flowiseai.com/configuration/authorization/app-level - Flowise chatflow-level API keys: https://docs.flowiseai.com/configuration/authorization/chatflow-level - Flowise environment variables (`PORT`, `NUMBER_OF_PROXIES`, `FLOWISE_SECRETKEY_OVERWRITE`): https://docs.flowiseai.com/configuration/environment-variables - Flowise prediction API (401 without key): https://docs.flowiseai.com/api-reference/prediction - Flowise deployment with nginx and certbot: https://docs.flowiseai.com/configuration/deployment/digital-ocean - Langflow API keys and authentication: https://docs.langflow.org/api-keys-and-authentication - Langflow environment variables (`LANGFLOW_HOST`, `LANGFLOW_PORT`, SSL files): https://docs.langflow.org/environment-variables - Langflow production best practices (`LANGFLOW_SECRET_KEY` preflight): https://docs.langflow.org/deployment-prod-best-practices - LibreChat `.env` reference: https://www.librechat.ai/docs/configuration/dotenv - LibreChat authentication system: https://www.librechat.ai/docs/configuration/authentication - LibreChat OAuth2 and OIDC overview: https://www.librechat.ai/docs/configuration/authentication/OAuth2-OIDC - LibreChat Keycloak setup (`OPENID_*` variables): https://www.librechat.ai/docs/configuration/authentication/OAuth2-OIDC/keycloak - LibreChat Docker install (port 3080, first account is admin): https://www.librechat.ai/docs/local/docker - LibreChat nginx and TLS: https://www.librechat.ai/docs/remote/nginx - LibreChat v0.7.7 changelog (two-factor authentication): https://www.librechat.ai/changelog/v0.7.7 ====================================================================== ==> image-gen-uis.md ====================================================================== # Image-generation UIs: ComfyUI, Stable Diffusion WebUI, InvokeAI, Fooocus None of these tools ships real authentication by default, and each accepts arbitrary Python through custom nodes or extensions. **Binding one to a public interface is host compromise, not just data exposure.** Keep every instance on loopback and reach it only through a tunnel or an authenticated TLS proxy: [fronting-auth.md](fronting-auth.md), [cloudflare.md](cloudflare.md), [tailscale.md](tailscale.md), or a reverse proxy per [nginx.md](nginx.md)/[caddy.md](caddy.md). If you run any of these in a container, do not stop at the network boundary: a compromised custom node or extension can still reach whatever the container can reach, so apply [container-hardening.md](container-hardening.md) (a non-root user, a read-only filesystem where the tool allows it, no unnecessary mounts) as a second layer, not a substitute for keeping the port off the network. ## ComfyUI `--listen` with no argument binds to `0.0.0.0,::` (every IPv4 and IPv6 interface); given an address it binds only there. The default with the flag absent is `127.0.0.1`, and the default port is `8188`. There is no built-in login: the server accepts any workflow from anyone who can reach it. Custom nodes are the bigger risk. They run as plain Python with the same privileges as the server process; ComfyUI's own security update warns that `eval`/`exec` calls in a node are "direct attack vectors" for remote code execution. ComfyUI-Manager (the default node installer) had its own unauthenticated-RCE advisory, CVE-2025-67303 (GHSA-95pq-hr8p-f5g7): an unprotected alternate channel left the manager's data and configuration directories insufficiently protected by ComfyUI's web API access control, letting an attacker upload arbitrary files for full system compromise with no credentials at all. The fix spans both projects and needs both minimums together: ComfyUI v0.3.76 or later (adds the protected-directory API the fix depends on) and ComfyUI-Manager v3.38 or later (contains the fix itself). Keep both at or above those versions, and only install nodes you trust regardless. ```bash python main.py --listen 127.0.0.1 --port 8188 ``` Front it with a TLS proxy that adds login before anything reaches port 8188. If you must run untrusted workflows, `--disable-all-custom-nodes` starts the server with none of them loaded, and `--whitelist-custom-nodes FOLDER...` re-allows specific folders despite that flag; neither is a substitute for keeping the port off the network. ## AUTOMATIC1111 Stable Diffusion WebUI `--listen` launches gradio bound to `0.0.0.0` (default `False`, i.e. loopback); `--port` defaults to `7860`. `--gradio-auth user:pass` (comma-delimited for multiple users) or `--gradio-auth-path /path/to/file` requires a login before the UI loads; `--api-auth` does the same for the API. `--share` registers a public `*.gradio.live` relay URL, documented as intended for Colab, not a deployment mechanism, and it bypasses your network boundary entirely. `--enable-insecure-extension-access` reopens the extensions tab regardless of other flags and should stay off on anything reachable beyond loopback. ```bash python launch.py --port 7860 --gradio-auth "admin:REPLACE_WITH_LONG_RANDOM_VALUE" ``` Even with `--gradio-auth` set, put TLS in front; the flag alone only gates plaintext HTTP. `--server-name` sets an explicit hostname if you bind somewhere other than the default. ## InvokeAI InvokeAI's `invokeai.yaml` uses a flat schema (current as of InvokeAI 6.14.1, `schema_version: 4.0.2`): `host` (default `127.0.0.1`) and `port` (default `9090`) are top-level keys, not nested under a `Web Server` or `InvokeAI` section. Setting `host: 0.0.0.0` serves the local network with no login at all in the default single-user mode. `INVOKEAI_HOST` and `INVOKEAI_PORT` override the same settings from the environment. An experimental multi-user mode exists: add `multiuser: true` to `invokeai.yaml` to require per-user login (username and password, stateless JWT sessions), and `strict_password_checking: true` to enforce a minimum password (8+ characters, upper, lower, and a digit) rather than just warning on a weak one. Restarting the server logs every user out. Outside multi-user mode, treat InvokeAI as having no login and keep it on loopback regardless. ```yaml # invokeai.yaml, flat schema (schema_version 4.0.2, InvokeAI 6.14.1 and later) host: 127.0.0.1 port: 9090 multiuser: true strict_password_checking: true ``` ## Fooocus `--listen` exposes the UI to the network (optionally to a specific address); `--port` sets the port; `--share` registers a public `*.gradio.live` endpoint, same relay mechanism and same caution as above. Fooocus's own README states access is unauthenticated by default. Optional basic auth comes from an `auth.json` file with `user`/`pass` entries (no dedicated command-line auth flag exists); use it, but still keep the instance off any public interface. ```json [ {"user": "admin", "pass": "REPLACE_WITH_LONG_RANDOM_VALUE"} ] ``` ## Verify ```bash ss -tlnp | grep -E '8188|7860|9090' # each service on 127.0.0.1 only curl -s http://203.0.113.10:8188/ # ComfyUI from another host: connection refused curl -s http://203.0.113.10:7860/ # Stable Diffusion WebUI: connection refused curl -s http://203.0.113.10:9090/ # InvokeAI: connection refused curl -s -o /dev/null -w '%{http_code}\n' http://127.0.0.1:9090/api/v1/boards/ # from the host itself, InvokeAI multiuser mode: 401 # without a Bearer token, never the app itself curl -sI https://imagegen.example.com/ # via the proxy: TLS, and a login prompt # or 401 without credentials, before the UI loads ``` ## Common mistakes - Adding `--listen`/`host: 0.0.0.0` "just to test from my phone" and forgetting it is still set a week later. - Treating `--share` (Stable Diffusion WebUI, Fooocus) as a deployment option instead of a short-lived demo link. - Installing a custom node or extension without reading it, on the assumption that "it's just a UI". - Relying on `--gradio-auth` or Fooocus's `auth.json` alone: single-factor credentials over plain HTTP still leak on the wire without a TLS proxy in front. - Assuming InvokeAI's multi-user mode is on by default; the base install has no login at all, so loopback binding still carries the whole burden. ## Sources (checked September 2026) - ComfyUI Startup Flags (`--listen`, `--port` defaults): https://docs.comfy.org/development/comfyui-server/startup-flags - ComfyUI custom node security standards (eval/exec prohibited): https://docs.comfy.org/registry/standards - ComfyUI 2025 Jan Security Update (custom node code-execution risk): https://blog.comfy.org/p/comfyui-2025-jan-security-update - ComfyUI-Manager security advisory, CVE-2025-67303, GHSA-95pq-hr8p-f5g7 (both minimum versions): https://github.com/Comfy-Org/ComfyUI-Manager/security/advisories/GHSA-95pq-hr8p-f5g7 - AUTOMATIC1111 Command Line Arguments and Settings wiki: https://github.com/AUTOMATIC1111/stable-diffusion-webui/wiki/Command-Line-Arguments-and-Settings - InvokeAI YAML Config (host/port defaults): https://invoke.ai/configuration/invokeai-yaml/ - InvokeAI Multi-User Administrator Guide: https://invoke.ai/features/multi-user-mode/admin-guide/ - Fooocus repository README (`--listen`, `--share`, auth.json): https://github.com/lllyasviel/Fooocus ====================================================================== ==> chat-uis.md ====================================================================== # Self-hosted chat and agent UIs: AnythingLLM, LobeChat, Chainlit, OpenHands These tools hold provider API keys and full conversation history, and several are wide open the moment they start. Bind every one to loopback, front it with TLS and a login ([caddy.md](caddy.md)/[nginx.md](nginx.md), [free-certificates.md](free-certificates.md)), and turn on the tool's own authentication as a second layer, never as a substitute for the network boundary. See also [open-webui.md](open-webui.md) for the Open WebUI case, [secrets.md](secrets.md) for the provider keys these apps store, and [fronting-auth.md](fronting-auth.md)/[mfa.md](mfa.md) for the identity layer in front. ## AnythingLLM Security features apply to the Docker deployment. Single-user mode offers an optional "Password Protect Instance" toggle: once set, anyone with that one password can use the instance, change any setting, and read every chat, so treat it as a screen door, not a real access-control boundary. Multi-user mode is the documented preferred setup: it adds Admin (full access including logs and analytics), Manager (all workspaces, no LLM/embedder/vector-database settings), and Default (only explicitly assigned workspaces) roles, each requiring its own login. Multi-user mode cannot be reverted to single-user once enabled, so decide before turning it on. ```bash docker run -d -p 127.0.0.1:3001:3001 mintplexlabs/anythingllm ``` Keep it on loopback regardless of which mode you choose, and put the proxy's own TLS and login in front. ## LobeChat `KEY_VAULTS_SECRET` is the key that encrypts stored provider credentials (AES-GCM); generate it with `openssl rand -base64 32`, and once set, never change it, or previously encrypted data becomes unreadable. LobeHub's own basic-variables page describes it loosely as "a password to access the LobeHub service", but its own warning on the same entry says the key is used to encrypt sensitive data: treat it as the encryption key, not the deployment's login gate. Real per-user login comes from LobeChat's Better Auth service: `AUTH_SECRET` (required, generated the same way) signs sessions, `AUTH_SSO_PROVIDERS` lists enabled SSO providers (for example `google,github,microsoft`) alongside the matching provider credentials each one needs (for example `AUTH_GOOGLE_ID`/`AUTH_GOOGLE_SECRET`), and `AUTH_DISABLE_EMAIL_PASSWORD=1` forces SSO-only login, hiding the password form entirely. `AUTH_ALLOWED_EMAILS` restricts registration to specific addresses or domains, but it defaults to empty, which allows every email through, so set it explicitly rather than relying on federation alone to gate access. ```bash docker run -d -p 127.0.0.1:3210:3210 \ -e KEY_VAULTS_SECRET=REPLACE_WITH_LONG_RANDOM_VALUE \ -e AUTH_SECRET=REPLACE_WITH_LONG_RANDOM_VALUE \ -e AUTH_DISABLE_EMAIL_PASSWORD=1 \ -e AUTH_SSO_PROVIDERS=google \ -e AUTH_GOOGLE_ID=REPLACE_WITH_GOOGLE_OAUTH_CLIENT_ID \ -e AUTH_GOOGLE_SECRET=REPLACE_WITH_GOOGLE_OAUTH_CLIENT_SECRET \ -e AUTH_ALLOWED_EMAILS=admin@example.com,example.com \ lobehub/lobe-chat ``` MFA: enforce it at whichever SSO provider you list in `AUTH_SSO_PROVIDERS`; LobeChat's own login has no second factor of its own. ## Chainlit Chainlit applications are public by default: no login, no gate, anyone who reaches the port gets the chat. Set `CHAINLIT_AUTH_SECRET` (generate one with `chainlit create-secret`; changing it logs out every user) and implement at least one auth callback: password authentication, OAuth, or header-based authentication. A callback that returns `None` refuses the login. There is no built-in MFA; put it behind an identity provider that enforces a second factor, or an [Authelia](https://www.authelia.com/)-fronted proxy. ```python import chainlit as cl @cl.password_auth_callback def auth_callback(username: str, password: str): if username == "admin" and password == "REPLACE_WITH_LONG_RANDOM_VALUE": return cl.User(identifier="admin") return None ``` ## OpenHands OpenHands is built for a single user on their own workstation: the project's own FAQ states there is no built-in authentication, isolation, or scalability for shared use, and the open-source build authorizes API access with one shared key rather than per-user identity. It also executes agent-generated code against your workspace, with the isolation depending entirely on your deployment choice (a local process backend runs with your user's permissions; container backends can be weakened by broad mounts, privileged mode, or Docker-socket access). Do not expose it to anyone you would not hand a shell to. The project documents a Hardened Docker Installation guide for deployments that must sit on a shared network; multi-tenant use is an enterprise offering, not something the open-source build supports. The documented quickstart itself runs `docker run ... -p 3000:3000 ... openhands/openhands`, which maps every interface, not loopback; change that to `-p 127.0.0.1:3000:3000` before anything else. Reach it only through an authenticated tunnel; there is no login screen to add in front of it, so identity has to come entirely from the proxy or tunnel layer. ## Verify ```bash ss -tlnp | grep -E '3001|3210|3000' # each UI on 127.0.0.1 only curl -s http://203.0.113.10:3210/ # from another host: connection refused curl -s https://chat.example.com/api/some-endpoint # without a key/token: 401 curl -sI https://chat.example.com/ # via the proxy: TLS, login required # LobeChat SSO: attempt to register/sign in with a Google account that has never registered and is # NOT listed in AUTH_ALLOWED_EMAILS; expect rejection at registration, before any account or session # is created (AUTH_ALLOWED_EMAILS gates new registration; it does not revoke an already-registered # user's existing session) ``` ## Common mistakes - Leaving Chainlit unauthenticated because "it's just for testing"; public by default means public the moment it is reachable. - Treating AnythingLLM's single instance password as equivalent to per-user accounts; it grants full admin to whoever has it. - Running OpenHands with a shared, long-lived API key exposed on the same network as untrusted users. - Rotating LobeChat's `KEY_VAULTS_SECRET` after data has been encrypted with it, which makes that data unreadable. ## Sources (checked September 2026) - AnythingLLM security and access documentation: https://docs.anythingllm.com/features/security-and-access - LobeHub environment variables (KEY_VAULTS_SECRET): https://lobehub.com/docs/self-hosting/environment-variables/basic - LobeHub authentication service environment variables (Better Auth): https://lobehub.com/docs/self-hosting/environment-variables/auth - Chainlit authentication overview: https://docs.chainlit.io/authentication/overview - Chainlit password authentication (`@cl.password_auth_callback` signature and example): https://docs.chainlit.io/authentication/password - OpenHands FAQs (single-user design, no built-in auth, sandboxing, hardened deployment): https://docs.openhands.dev/openhands/usage/faqs - OpenHands local setup (default docker port mapping): https://docs.openhands.dev/openhands/usage/run-openhands/local-setup ====================================================================== ==> llm-observability.md ====================================================================== # LLM tracing and observability: Langfuse, Phoenix, Helicone, OpenTelemetry Collector These tools store full prompts, completions, and often the provider API keys used to generate them, so an exposed dashboard leaks your most sensitive data at once. Several ship with authentication off or with open signup enabled, so the default install is not safe to expose. ## Langfuse (self-hosted) Email/password authentication is enabled by default: anyone who can reach the URL can register their own account unless you turn signup off. Set `AUTH_DISABLE_SIGNUP=true` to block new registrations, including a user accepting a project invite without an existing account; set `AUTH_DISABLE_USERNAME_PASSWORD=true` to require SSO instead of a password entirely. SSO runs through Auth.js against Google, GitHub, GitLab, Azure AD/Entra ID, Okta, Auth0, Keycloak, or a custom OIDC provider; `NEXTAUTH_URL` must be set correctly for any method other than email/password. `AUTH_SESSION_MAX_AGE` sets the session lifetime in minutes (default 43200, thirty days; five minutes is the enforced floor). The ingestion and public API are authenticated separately from the UI session: a project's public key (username) and secret key (password) are sent as HTTP Basic Auth, issued from Project Settings, and unrelated to a user's login credentials. Put MFA at the identity provider per [mfa.md](mfa.md); Langfuse's own login has none. ## Arize Phoenix (self-hosted) Authentication is disabled by default, "as you may be just trying Phoenix for the very first time or have Phoenix deployed in a VPC" in the vendor's own words: anyone who reaches the UI has full read and write access with no login at all. Set `PHOENIX_ENABLE_AUTH=True` and `PHOENIX_SECRET` (a long random value used to sign session tokens) to turn it on. Enabling auth on a running instance stops trace collection and blocks all API access until API keys exist, so provision keys before or immediately after the flip, not after. Once auth is on, a system key (admin-created, acts for the whole instance) or a user key authenticates every request via `PHOENIX_API_KEY` sent as an `Authorization: Bearer` header; collectors and SDKs need one to keep sending traces. Phoenix has no native MFA; add an identity-aware proxy in front per [mfa.md](mfa.md). ## Helicone (self-hosted) The manual self-host guide's default login is `test@helicone.ai` / `password`, documented as a local trial credential; the fetched setup and manual-deployment pages do not cover changing it for production, disabling signup, or any hardening steps for exposing the dashboard beyond a laptop, so treat that gap as unverified rather than assuming a safe default exists. Do not expose this UI without your own layer in front. Ingestion runs through the separate AI Gateway component and provider keys configured in its environment, not through the web session; store those keys per [secrets.md](secrets.md), never in the repository. Keep both the dashboard and the gateway on a private network or behind an identity-aware fronting layer ([fronting-auth.md](fronting-auth.md), [nginx.md](nginx.md), [caddy.md](caddy.md)) with MFA ([mfa.md](mfa.md)). ## OpenTelemetry Collector The Collector is the pipe these tools (and others) receive traces through, and it ships with no security applied until you configure it. The project's own hardening guidance: bind receivers to a specific interface or `localhost` (for example `127.0.0.1:4317`), never the default all-interfaces listener, unless a proxy or mesh in front needs the wider bind; require TLS on every receiver and exporter; and attach an authenticator extension, such as `basicauth` (htpasswd-style credentials, or a static `client_auth` username/password for outgoing calls) or `bearertokenauth` (a static or file-backed token sent as an `Authorization` header), to any receiver that accepts data from outside the host. An extension is wired to a receiver with an `auth.authenticator` key naming the extension. Build or run only the receivers, processors, and exporters you use, since every enabled component is attack surface, and run the process as a non-root user. ## Verify ```bash ss -tlnp | grep -E ':(3000|6006|4317|4318) ' # loopback or private only, never 0.0.0.0 curl -sI --max-time 5 http://langfuse.example.com/api/public/health # dashboard: a login page or 401, never data curl -sS -o /dev/null -w '%{http_code}\n' https://langfuse.example.com/api/public/projects # 401 without -u public-key:secret-key curl -sS -o /dev/null -w '%{http_code}\n' http://otel-collector.internal:4318/v1/traces -d '{}' # rejected without the configured auth header ``` A dashboard that renders traces, prompts, or provider keys without a login is a finding; so is an OTLP port that accepts spans with no credential at all. ## Common mistakes - Leaving Phoenix's or Helicone's auth off "because it's just internal" on a host with a public IP. - Enabling `PHOENIX_ENABLE_AUTH` without creating an API key first, which locks out collectors mid-flight. - Publishing the OTLP gRPC/HTTP ports (4317/4318) to `0.0.0.0` because a docker-compose example did. - Assuming an ingestion key protects the UI, or a UI login protects the ingestion endpoint; they are separate credentials in Langfuse and Phoenix alike. ## Sources (checked September 2026) - Langfuse, authentication and SSO (signup default, `AUTH_DISABLE_SIGNUP`, `AUTH_DISABLE_USERNAME_PASSWORD`, SSO providers, `AUTH_SESSION_MAX_AGE`, `NEXTAUTH_URL`): https://langfuse.com/self-hosting/security/authentication-and-sso - Langfuse, public API authentication (Basic Auth with project public/secret key): https://langfuse.com/docs/api-and-data-platform/features/public-api - Arize Phoenix, authentication (`PHOENIX_ENABLE_AUTH`, `PHOENIX_SECRET`, system and user API keys, `PHOENIX_API_KEY`): https://arize.com/docs/phoenix/self-hosting/features/authentication - Helicone, self-hosted deployment (default `test@helicone.ai` / `password` login): https://docs.helicone.ai/getting-started/self-deploy and https://docs.helicone.ai/getting-started/self-host/manual - OpenTelemetry, Collector security best practices (bind addresses, TLS, authenticator extensions, minimal components, non-root): https://opentelemetry.io/docs/security/config-best-practices/ - OpenTelemetry Collector Contrib, `basicauthextension` (htpasswd, `client_auth`, `auth.authenticator` wiring): https://github.com/open-telemetry/opentelemetry-collector-contrib/tree/main/extension/basicauthextension ====================================================================== ==> workflow-orchestrators.md ====================================================================== # Workflow and agent orchestrators: Prefect, Dagster, Airflow, Temporal, Flower These webservers and UIs schedule and trigger arbitrary code execution across your infrastructure, and most ship with no authentication at all. Keep every one of them off the public internet and add auth before anyone but you can reach the port. ## Prefect (self-hosted server) There is no default authentication; `prefect server start` accepts unauthenticated API calls until you set one up. Basic Auth is a single administrator/password string, set on the server with `PREFECT_SERVER_API_AUTH_STRING` (or the `server.api.auth_string` setting) and the identical value on every client with `PREFECT_API_AUTH_STRING` (`api.auth_string`); the UI prompts for the string on first load. This is unrelated to Prefect Cloud: `PREFECT_API_KEY` authenticates only to Prefect Cloud, and if it happens to be set alongside `PREFECT_API_AUTH_STRING` on a client talking to a self-hosted server, the key takes precedence and the request fails with 401. Store the auth string in a secret manager or a private `.env` file, never in the repository ([secrets.md](secrets.md)). ## Dagster (OSS) The `dagster-webserver` (reachable by default around port 3000 in local dev) has no built-in authentication or access control; the documentation for the open-source webserver does not describe a login of any kind. Put it entirely behind an identity-aware fronting layer ([fronting-auth.md](fronting-auth.md)) or your own reverse proxy with its own authentication ([nginx.md](nginx.md), [caddy.md](caddy.md)) plus MFA ([mfa.md](mfa.md)), and never publish the port directly. ## Apache Airflow Airflow's own security model states it plainly: "Airflow doesn't support unauthenticated users by default" and "Airflow is not designed to be exposed to untrusted users on the public internet"; every user of the UI and API is assumed to be authenticated and known, and keeping it off the public internet is the deployment manager's responsibility, not something the software enforces for you. Access is governed by a pluggable "auth manager". The default is the Simple Auth Manager, which the documentation marks for development and testing only: it prints a warning banner on login, and its users and roles (`viewer`, `user`, `op`, `admin`) come from `simple_auth_manager_users` in `[core]` (for example `bob:admin,peter:viewer`), with a password auto-generated per user and printed to the webserver logs unless you set your own. For production, configure the FAB auth manager (`[fab] auth_backends`) against LDAP, OAuth, or another real identity backend instead, and put MFA at that identity provider ([mfa.md](mfa.md), [identity-providers.md](identity-providers.md)). ## Temporal (self-hosted) With no authorizer configured, the server runs the default `noopAuthorizer`, which the documentation says "allows every API request, with no authentication or access control" at all, including administrative operations. Configure an `Authorizer` together with a `ClaimMapper` (`temporal.WithAuthorizer()`, `temporal.WithClaimMapper()`, or the equivalent `config.Global.Authorization` keys) so every gRPC call to the Temporal Service is checked against a mapped claim before anything runs. This is entirely separate from Web UI login: the UI's own config reference documents an `auth.providers` block with `enabled`, `type: oidc`, `providerUrl`, `issuerUrl`, `clientId`, `clientSecret`, `callbackUrl`, and `scopes` for OIDC SSO into the dashboard, but that only gates the UI, not the server API a worker or CLI talks to directly. mTLS secures internode and frontend traffic separately again. Set up both layers; MFA comes from whichever identity provider the UI's OIDC settings point at. ## Flower (for Celery) Flower binds every interface by default (`--address` is empty, meaning all interfaces; `--port` defaults to 5555) with authentication disabled unless you configure it. `--basic-auth="user1:password1,user2:password2"` turns on HTTP Basic Auth with a comma-separated credential list; OAuth 2.0 login against Google, GitHub, GitLab, or Okta is enabled by setting `--auth_provider` to the provider's handler class plus `--oauth2_key`, `--oauth2_secret`, `--oauth2_redirect_uri`, and an `--auth` regular expression of the email addresses allowed to sign in. Prefer OAuth against a provider that enforces MFA over Basic Auth alone ([mfa.md](mfa.md)), and bind `--address=127.0.0.1` behind a proxy rather than relying on Basic Auth as the only control. ## Verify ```bash ss -tlnp | grep -E ':(4200|3000|8080|7233|8233|5555) ' # loopback or private only, never 0.0.0.0 curl -sS -o /dev/null -w '%{http_code}\n' http://prefect.internal:4200/api/health # 401 without the auth string once configured curl -sS -o /dev/null -w '%{http_code}\n' -u '' http://dagster.internal:3000/ # must be blocked at the proxy, not Dagster itself curl -sSI https://airflow.example.com/ # login redirect, never the DAG list curl -sS -o /dev/null -w '%{http_code}\n' http://flower.internal:5555/ # 401 once --basic-auth or OAuth is set ``` A DAG list, flow run, task graph, or worker pool that renders without a credential is a finding; so is a Temporal Service that accepts a gRPC call with no claim behind it, since the UI login does not cover that path. ## Common mistakes - Assuming Airflow's Simple Auth Manager, meant for development, is acceptable in production because it technically requires a password. - Configuring Temporal's UI SSO and believing the server API is now protected too; they are independent. - Setting `PREFECT_API_KEY` on a client that should be using `PREFECT_API_AUTH_STRING` against a self-hosted server, then debugging the resulting 401 as a server problem. - Publishing Flower's `5555` or Dagster's `3000` straight to the internet "for the team" with no proxy. ## Sources (checked September 2026) - Prefect, security settings (`PREFECT_SERVER_API_AUTH_STRING`, `PREFECT_API_AUTH_STRING`, Cloud API keys taking precedence and causing 401): https://docs.prefect.io/v3/advanced/security-settings - Prefect, self-hosted server on Windows (default port 4200): https://docs.prefect.io/v3/how-to-guides/self-hosted/server-windows - Dagster, webserver and UI (default local port, no documented built-in auth): https://docs.dagster.io/guides/operate/webserver - Apache Airflow, security overview: https://airflow.apache.org/docs/apache-airflow/stable/security/ - Apache Airflow, quickstart (default port 8080): https://airflow.apache.org/docs/apache-airflow/stable/start.html - Apache Airflow, security model ("doesn't support unauthenticated users", "not designed to be exposed... to untrusted users on the public internet"): https://airflow.apache.org/docs/apache-airflow/stable/security/security_model.html - Apache Airflow, Simple auth manager (default, dev/test only, `simple_auth_manager_users`, generated passwords): https://airflow.apache.org/docs/apache-airflow/stable/core-concepts/auth-manager/simple/index.html - Temporal, self-hosted security (`noopAuthorizer` default, `Authorizer`, `ClaimMapper`): https://docs.temporal.io/self-hosted-guide/security - Temporal, Web UI configuration reference (`auth.providers`, `enabled`, `type: oidc`, `providerUrl`, `clientId`, `clientSecret`, `callbackUrl`, `scopes`): https://docs.temporal.io/references/web-ui-configuration - Temporal, CLI server reference (default frontend gRPC port 7233, Web UI port 8233): https://docs.temporal.io/cli/server - Flower, configuration (`--address`, `--port` 5555 default, `--basic-auth`, `--auth_provider`, `--oauth2_key`, `--oauth2_secret`, `--oauth2_redirect_uri`, `--auth`): https://flower.readthedocs.io/en/latest/config.html ====================================================================== ==> admin-uis.md ====================================================================== # Admin panels: phpMyAdmin, pgAdmin, mongo-express, Grafana, Prometheus Database and monitoring panels are the most-scanned targets on the internet, and several ship with known default credentials. One rule dominates everything tool-specific below: **an admin panel is never reachable from the public internet.** Bind it to loopback and reach it through SSH port forwarding, a VPN or tailnet ([tailscale.md](tailscale.md)), or Cloudflare Access ([cloudflare.md](cloudflare.md)); anything public sits behind a TLS proxy with its own authentication ([nginx.md](nginx.md), [caddy.md](caddy.md)) plus MFA ([mfa.md](mfa.md)). ## mongo-express Ships with basic auth `admin`:`pass` by default; its own README calls this unsafe. Set your own credentials and keep it private: ``` ME_CONFIG_BASICAUTH_USERNAME= ME_CONFIG_BASICAUTH_PASSWORD= ``` These control only the web login; MongoDB credentials go in `ME_CONFIG_MONGODB_URL` ([mongodb.md](mongodb.md) hardens the database itself). ## Grafana - First sign-in uses `admin`/`admin` and prompts for a new password; set a strong one immediately and create individual accounts for everyone else. - Disable anonymous access if it was enabled, and prefer SSO with MFA enforced at the identity provider. - Native HTTPS in `grafana.ini`: ```ini [server] protocol = https cert_file = /etc/grafana/grafana.crt cert_key = /etc/grafana/grafana.key ``` ## Prometheus No authentication at all by default. Give it a web configuration file and start with `--web.config.file=web.yml`: ```yaml basic_auth_users: admin: $2b$12$REPLACE_WITH_BCRYPT_HASH # htpasswd -nB admin, hash part ``` The same file carries TLS (`tls_server_config` with `cert_file` and `key_file`; see the Prometheus TLS guide below). Validate with `promtool check web-config web.yml`. Exporters and Alertmanager need the same treatment. ## phpMyAdmin and pgAdmin Neither belongs on a public vhost. Serve them only behind the proxy-level TLS and authentication of your web server guide, restrict by source IP where the proxy supports it, and keep them updated; both are perennial exploit targets. pgAdmin in server mode has its own login; treat its accounts per [authentication.md](authentication.md). ## RedisInsight and similar tools Keep them on loopback or a private network and reach them through the tunnels above. When in doubt, apply the generic pattern: loopback bind, TLS proxy, proxy or SSO authentication, MFA. ## Verify ```bash ss -tlnp # panels bound to 127.0.0.1 only curl -sI https://panel.example.com/ # 401/403 or a login redirect, never a dashboard ``` Test each panel's URL from outside your network; a dashboard that renders without a login is a finding. ## Sources (checked September 2026) - mongo-express README (defaults and variables): https://github.com/mongo-express/mongo-express - Grafana configuration and HTTPS: https://grafana.com/docs/grafana/latest/setup-grafana/configure-grafana/ and https://grafana.com/docs/grafana/latest/setup-grafana/set-up-https/ - Prometheus basic auth and TLS guides: https://prometheus.io/docs/guides/basic-auth/ and https://prometheus.io/docs/guides/tls-encryption/ - phpMyAdmin documentation: https://www.phpmyadmin.net/docs/ and pgAdmin documentation: https://www.pgadmin.org/docs/ ====================================================================== ==> devops-uis.md ====================================================================== # DevOps panels: Portainer, Coolify, Dokploy, Nginx Proxy Manager, Vaultwarden, Kubernetes Dashboard, Jenkins, Gitea, Uptime Kuma, and the Docker API These panels control hosts, containers, clusters, deploy keys, and secrets; a login to one of them is a login to everything it manages. One rule dominates everything tool-specific below: **a DevOps panel is never reachable from the public internet.** Bind it to loopback or a private interface and reach it through SSH port forwarding, a tailnet ([tailscale.md](tailscale.md)), or Cloudflare Access ([cloudflare.md](cloudflare.md)); anything that must be public sits behind a TLS proxy with its own authentication ([nginx.md](nginx.md), [caddy.md](caddy.md)) and the admin login gets MFA ([mfa.md](mfa.md)). Several of these tools create their administrator on first visit, so whoever reaches a fresh install first owns it: create the account before the port is reachable by anyone else. ## Portainer - Serves the UI over HTTPS on `9443` (self-signed by default; supply your own certificate or front it per [nginx.md](nginx.md)). `9000` is the legacy HTTP port and stays unpublished; `8000` is the Edge agent tunnel and is only published when Edge Compute is in use. - New instances require a setup token to complete first-time setup; it is in the server logs on the `setup_token=` line. The first user is an administrator, and its password must be at least 12 characters. - Authentication is internal, LDAP, Active Directory, or OAuth (Microsoft, Google, GitHub, or a custom provider). Portainer's own login has no second factor in its documentation; use OAuth against a provider that enforces MFA ([identity-providers.md](identity-providers.md)). - The container mounts `/var/run/docker.sock`, which is root on the host; a Portainer admin is a host root user ([docker.md](docker.md)). ## Coolify and Dokploy Both are one-script installs that hold your servers' SSH private keys, Git provider credentials, and application secrets, and both create their administrator on first visit. - Coolify's dashboard answers on `8000` over plain HTTP after install. Its documentation says to create the admin account immediately, because whoever reaches the registration page first can gain full control of the server. Set the instance a custom domain on the `/settings` page; the integrated proxy (Traefik by default, or Caddy) then issues and renews a Let's Encrypt certificate, and the firewall guide says ports `8000`, `6001`, and `6002` can be closed once the dashboard is reached through that domain. Docker's iptables rules bypass UFW, so restrict these ports with the cloud provider's firewall ([cloud-firewalls.md](cloud-firewalls.md)). Coolify requires the server SSH key to have no passphrase, so the key material inside Coolify is the whole secret. - Dokploy's UI answers on `3000`; ports `80` and `443` belong to its Traefik. The first visit is the setup page that creates the admin account. Configure a domain with a Let's Encrypt or custom certificate for the panel under Domains, then remove the published `3000` binding so the panel is reachable only through the proxy. ## Nginx Proxy Manager The admin UI is on port `81`; the proxy itself is on `80`/`443`. Port `81` is never published to the internet: bind it to `127.0.0.1` in the compose file (`'127.0.0.1:81:81'`) and reach it through a tunnel. A default admin user is created on the first run; change the initial admin credentials on first login, before anything else. Since it terminates TLS for every site behind it, treat its login like a root password. ## Vaultwarden - The web vault needs HTTPS (browsers expose the crypto APIs it uses only in secure contexts). The wiki recommends a reverse proxy for TLS ([caddy.md](caddy.md), [nginx.md](nginx.md)) and rates the built-in `ROCKET_TLS` as not recommended. Set `DOMAIN=https://vault.example.com`. - `SIGNUPS_ALLOWED=false` (the default is `true`, letting anyone who reaches the instance register). Organization owners and admins can still invite users while `INVITATIONS_ALLOWED=true`; `SIGNUPS_DOMAINS_WHITELIST` admits specific email domains. - The admin page is disabled unless `ADMIN_TOKEN` is set. Store it as an argon2id PHC string generated with `vaultwarden hash` (or `docker run --rm -it vaultwarden/server /vaultwarden hash`), never plaintext; in a compose `environment:` block every `$` in the hash becomes `$$`. Enable HTTPS before enabling the admin page. Admin sessions expire after 20 minutes by default. - Inside a container it listens on `80` (`8000` outside Docker); publish it only to loopback or the proxy network. ## Kubernetes Dashboard - As of September 2026 the Kubernetes documentation marks the Dashboard deprecated and unmaintained (the repository was archived in January 2026) and points new installs to Headlamp; the pattern below applies to any cluster UI. - Do not expose it with a LoadBalancer or Ingress. Reach it from the operator's machine with `kubectl -n kubernetes-dashboard port-forward svc/kubernetes-dashboard-kong-proxy 8443:443` and open `https://localhost:8443`; the UI is then reachable only from that machine. - Login is by bearer token of a ServiceAccount with a minimal RBAC role; the tutorial's sample user is cluster-admin and is for demonstration only. On the older 2.x releases, `--enable-skip-login` and `--enable-insecure-login` both default to `false`; never turn them on. - A ServiceAccount token is a machine credential, so configuring OIDC on the API server puts no MFA on this login. Human MFA comes from the access path (a port-forward over SSH from a machine that already required it, a tailnet per [tailscale.md](tailscale.md), or Cloudflare Access per [cloudflare.md](cloudflare.md)) or from a UI that performs an identity-provider login itself; see [mfa.md](mfa.md). ## Jenkins - Authentication (the security realm: Jenkins' own user database, LDAP, and others) and authorization (the strategy) are configured separately. The setup wizard leaves a single admin in the local database; do not enable account signup for that database, since new accounts inherit whatever the strategy grants to authenticated users. - Use the Matrix Authorization Strategy (global or project-based) and grant nothing significant to `anonymous` or to `authenticated`; granting Overall/Administer to anonymous is the same as "Anyone can do anything". - Leave CSRF protection on. It has no UI switch and is only disabled by the `hudson.security.csrf.GlobalCrumbIssuerConfiguration.DISABLE_CSRF_PROTECTION` system property; the documentation says to keep it enabled even on private networks. - Inbound agents use a fixed or random TCP port, or WebSocket over the same HTTPS port with no extra listener; prefer WebSocket or keep the agent port on the private network. - Jenkins has no native second factor; put the web UI behind SSO with MFA at the provider or behind an identity layer ([mfa.md](mfa.md)). ## Gitea - It listens on `HTTP_ADDR = 0.0.0.0`, `HTTP_PORT = 3000` by default; set `HTTP_ADDR = 127.0.0.1` behind a proxy, or `PROTOCOL = https` with `CERT_FILE` and `KEY_FILE` to terminate TLS itself. - In `[service]`, `DISABLE_REGISTRATION = true` (default `false`) and, for a private forge, `REQUIRE_SIGNIN_VIEW = true` (default `false`). - Users enrol TOTP or a WebAuthn key under Settings > Security; `TWO_FACTOR_AUTH = enforced` in `[security]` (Gitea 1.24 and later) requires it. With MFA on, Git over HTTP uses an access token instead of the password, and tokens bypass MFA, so scope them and revoke unused ones ([machine-auth.md](machine-auth.md)). ## Uptime Kuma Listens on `3001` and is WebSocket-based, so a reverse proxy in front needs the `Upgrade` and `Connection` headers. Open it right after the first start and finish the account setup before anyone else can; then enable the 2FA the project lists as a feature for that account. Status pages can be public; the dashboard is not. ## The Docker API The daemon socket is root on the host: anyone who can talk to it can run a privileged container. Never start `dockerd` with `-H tcp://0.0.0.0:2375`; Docker's documentation calls remote access without TLS not recommended, and scanners find open 2375 within minutes. Two acceptable remote paths: ```bash # 1. SSH to the Unix socket (nothing new listens on the network) export DOCKER_HOST=ssh://docker-user@host1.example.com docker context create remote --docker host=ssh://docker-user@host1.example.com # 2. TLS with client certificates on 2376, all four flags set dockerd --tlsverify --tlscacert=ca.pem --tlscert=server-cert.pem --tlskey=server-key.pem -H=0.0.0.0:2376 export DOCKER_HOST=tcp://$HOST:2376 DOCKER_TLS_VERIFY=1 DOCKER_CERT_PATH=~/.docker/zone1/ ``` Without `--tlsverify` the daemon does not check client certificates. Firewall 2376 to the operator addresses even with TLS ([docker.md](docker.md)). ## Dozzle - Dozzle reads the Docker socket to show logs, the same host-level access the Docker API section above describes ([docker.md](docker.md)); a Dozzle login is a login to the host. - Authentication is off unless configured. Generate a `users.yml` with `docker run -it --rm amir20/dozzle generate admin > users.yml` (omit `--password` and Dozzle prompts for it on stdin, so it never lands in shell history), mount that file into the container, and set `DOZZLE_AUTH_PROVIDER=simple`; or set `DOZZLE_AUTH_PROVIDER=forward-proxy` to delegate login to a fronting proxy such as Authelia, Authentik, or Cloudflare Access. - Container actions (start, stop, recreate) and shell access into a running container can be turned on; leave both off unless a specific workflow needs them, since either turns a log viewer into remote command execution on the host. - Bind it to loopback or a private interface and reach it through SSH port forwarding, a tailnet, or Access, with MFA at the fronting layer, like every panel above. ## Docker Registry (`registry:2`) - The reference registry image ships with no authentication at all: anyone who reaches the port can push and pull every image, and TLS must be configured before any authentication scheme works, since credentials would otherwise cross the wire in clear text. - Restrict access with htpasswd basic authentication, a token server, or a registry distribution that has its own authentication built in. If using the registry's own native htpasswd auth provider (set directly in the registry's `config.yml`), credentials must be bcrypt-hashed (`htpasswd -B`); the registry rejects any other hash format. If instead a reverse proxy sits in front and does its own basic authentication from its own htpasswd file, that proxy's own hashing rules apply, not the registry's. - Bind it to loopback or a private interface and reach it through SSH port forwarding, a tailnet, or Access, with MFA at the fronting layer. ## Filebrowser - Ships with a default administrator account created on first run (historically `admin`/`admin`). Change it immediately, before the instance is reachable by anyone else. The project's own repository (archived on September 1, 2026) says not to expose it directly to the internet. - Bind it to loopback or a private interface and reach it through SSH port forwarding, a tailnet, or Access, with MFA at the fronting layer; never publish a file-serving admin panel. ## Node-RED - The editor and admin API on `1880` have no authentication at all by default; anyone who reaches the port can view, deploy, and modify flows. - Set `adminAuth` in `settings.js` with bcrypt-hashed user passwords (`node-red admin hash-pw` generates the hash), and set `credentialSecret` to a value you control, since Node-RED otherwise generates one for you and stored credentials are only as protected as that secret. - Bind it to loopback or a private interface and reach it through SSH port forwarding, a tailnet, or Access, with MFA at the fronting layer; the editor is equivalent to a shell on whatever the flows can reach. ## Verify ```bash ss -tlnp | grep -E ':(9443|9000|8000|3000|81|3001|2375|2376) ' # 127.0.0.1 or absent, never 0.0.0.0 curl -skI https://panel.example.com/ # 401/403 or a login redirect, never a dashboard docker -H tcp://203.0.113.10:2375 info # must fail: connection refused or filtered docker -H tcp://203.0.113.10:2376 info # must fail without the client certificate ``` From outside the network, every panel URL is unreachable or shows a login; a page that renders host, container, or repository data without one is a finding. ## Sources (checked September 2026) - Portainer CE install on Docker (ports 9443, 9000, 8000): https://docs.portainer.io/start/install-ce/server/docker/linux - Portainer initial setup (setup token, first admin, 12-character password): https://docs.portainer.io/start/install-ce/server/setup - Portainer authentication and OAuth providers: https://docs.portainer.io/admin/settings/authentication and https://docs.portainer.io/admin/settings/authentication/oauth - Coolify installation, firewall, proxy, and DNS pages: https://coolify.io/docs/get-started/installation , https://coolify.io/docs/knowledge-base/server/firewall , https://coolify.io/docs/knowledge-base/proxy/overview , https://coolify.io/docs/knowledge-base/dns-configuration , https://coolify.io/docs/knowledge-base/server/openssh - Dokploy installation (ports 80, 443, 3000; admin setup; panel domain): https://docs.dokploy.com/docs/core/installation - Nginx Proxy Manager setup (port 81, default admin user): https://nginxproxymanager.com/setup/ - Vaultwarden wiki: admin page and ADMIN_TOKEN https://github.com/dani-garcia/vaultwarden/wiki/Enabling-admin-page , registration https://github.com/dani-garcia/vaultwarden/wiki/Disable-registration-of-new-users , HTTPS https://github.com/dani-garcia/vaultwarden/wiki/Enabling-HTTPS , and the `.env.template` https://github.com/dani-garcia/vaultwarden/blob/main/.env.template - Kubernetes Dashboard (deprecation, port-forward, token login): https://kubernetes.io/docs/tasks/access-application-cluster/web-ui-dashboard/ ; 2.x arguments: https://github.com/kubernetes/dashboard/blob/v2.7.0/docs/common/dashboard-arguments.md - Jenkins security: https://www.jenkins.io/doc/book/security/managing-security/ , https://www.jenkins.io/doc/book/security/access-control/ , https://www.jenkins.io/doc/book/security/csrf-protection/ - Gitea config cheat sheet and MFA: https://docs.gitea.com/administration/config-cheat-sheet and https://docs.gitea.com/usage/user-setting/multi-factor-authentication/ - Uptime Kuma README and reverse proxy wiki: https://github.com/louislam/uptime-kuma and https://github.com/louislam/uptime-kuma/wiki/Reverse-Proxy - Docker: protect the daemon socket https://docs.docker.com/engine/security/protect-access/ and remote access https://docs.docker.com/engine/daemon/remote-access/ - Dozzle authentication (DOZZLE_AUTH_PROVIDER, users.yml, actions and shell): https://dozzle.dev/guide/authentication - Docker Registry deployment (default authentication, TLS requirement): https://distribution.github.io/distribution/about/deploying/ - Filebrowser: https://filebrowser.org/ - Node-RED securing the runtime (adminAuth, credentialSecret): https://nodered.org/docs/user-guide/runtime/securing-node-red ====================================================================== ==> cors.md ====================================================================== # CORS: allow your origins, not everyone's CORS misconfiguration does not expose a port; it lets hostile websites use your users' browsers, cookies included, against your API. AI assistants reach for `Access-Control-Allow-Origin: *` the moment a browser console shows a CORS error; that is the wrong fix for any API that authenticates. ## Rules 1. **List exact origins.** `Access-Control-Allow-Origin` names the site(s) allowed to call the API from a browser: ``` Access-Control-Allow-Origin: https://app.example.com ``` 2. **Never combine `*` with credentials.** Browsers refuse `Access-Control-Allow-Origin: *` together with `Access-Control-Allow-Credentials: true`; configurations that "fix" this by reflecting whatever `Origin` header arrives recreate `*` for credentialed requests, which is worse. Reflect only origins checked against an explicit allow list. 3. **`*` is acceptable** only for genuinely public, unauthenticated, read-only resources. 4. **CORS is not authentication.** It controls browsers, not attackers with curl; every endpoint still authenticates per [authentication.md](authentication.md). ## Framework examples Express (`cors` package): ```js const cors = require('cors'); app.use(cors({ origin: ['https://app.example.com'], credentials: true })); ``` FastAPI: ```python from fastapi.middleware.cors import CORSMiddleware app.add_middleware( CORSMiddleware, allow_origins=["https://app.example.com"], allow_credentials=True, allow_methods=["GET", "POST"], allow_headers=["Authorization", "Content-Type"], ) ``` Keep the origin list in configuration per environment rather than hardcoding localhost origins into production. ## Verify ```bash curl -s -o /dev/null -D - https://api.example.com/data -H "Origin: https://evil.example" | grep -i access-control # expect: no Access-Control-Allow-Origin echoing the hostile origin curl -s -o /dev/null -D - https://api.example.com/data -H "Origin: https://app.example.com" | grep -i access-control # expect: your origin, and Allow-Credentials only if you use cookies ``` ## Sources (checked September 2026) - MDN: Cross-Origin Resource Sharing: https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS ====================================================================== ==> headers.md ====================================================================== # Security headers for your application TLS protects the transport; these response headers protect the page. Set them at the proxy (`add_header` in [nginx.md](nginx.md), `Header` in [apache.md](apache.md), `header` in Caddy), in app middleware (helmet for Express per [nodejs.md](nodejs.md), Django's security settings per [python.md](python.md)), or in a `_headers` file on static hosts. ## The set worth shipping ``` Strict-Transport-Security: max-age=31536000; includeSubDomains Content-Security-Policy: default-src 'self' X-Content-Type-Options: nosniff Referrer-Policy: strict-origin-when-cross-origin Permissions-Policy: camera=(), microphone=(), geolocation=() X-Frame-Options: DENY ``` Notes that keep these correct rather than decorative: - **HSTS** only after HTTPS provably works everywhere on the domain; `includeSubDomains` commits every subdomain to HTTPS. Leave the `preload` token off unless you have read what preload-list inclusion means; it is effectively irreversible. - **CSP** is the one that needs tailoring. Start from `default-src 'self'`, add the sources your app actually uses, and prefer nonces or hashes over `'unsafe-inline'` for scripts. Roll out with `Content-Security-Policy-Report-Only` first on an existing app so you see what would break before enforcing. - **frame-ancestors** in CSP supersedes `X-Frame-Options`; sending both keeps older scanners content and costs nothing. - Headers belong on every response, including error pages; setting them only on `200 /` is a common proxy misconfiguration (nginx `add_header` inheritance per [nginx.md](nginx.md)). ## Do not cache authenticated responses in a shared cache A CDN or shared proxy that keys its cache on the URL alone can serve one user's authenticated response to another. Set `Cache-Control: private` on authenticated responses, or `no-store` on the most sensitive ones, and mark cacheable only what is truly public. If a shared cache must hold authenticated content, configure it explicitly to vary on the cookie or the authorization header rather than relying on its default URL-only key. ## Verify ```bash curl -sI https://example.com/ | grep -iE 'strict-transport|content-security|x-content-type|referrer-policy|permissions-policy|x-frame' ``` Then scan with https://securityheaders.com/ from outside. A CSP that enforces without console errors on every page of the app is the finish line. For an authenticated route behind a shared cache, request the same URL as user A, then as user B, then anonymously, after warming the cache; each response must reflect only its own caller, never the one before it. ## Sources (checked September 2026) - MDN HTTP headers reference: https://developer.mozilla.org/en-US/docs/Web/HTTP - MDN Cache-Control: https://developer.mozilla.org/en-US/docs/Web/HTTP/Reference/Headers/Cache-Control - Security header scanner: https://securityheaders.com/ ====================================================================== ==> firebase-supabase.md ====================================================================== # Firebase and Supabase: the rules are the security These platforms handle TLS for you; the exposure works differently. Client SDKs talk to the backend using keys that ship in your frontend code and are **public by design** (the Firebase API key, the Supabase `anon` key). The only server-side gate between the internet and your data is the rules layer: Firebase security rules, or Postgres row-level security (RLS) on Supabase. AI-generated apps repeatedly ship with that layer open because "it worked in testing". ## Firebase - Every Firestore, Realtime Database, and Storage instance needs explicit security rules. Never deploy the all-open rule (`allow read, write: if true;` or `".read": true, ".write": true`); it exposes the entire datastore to anyone with your public config. - Require authentication and scope by user: ``` // Firestore example match /users/{userId}/{document=**} { allow read, write: if request.auth != null && request.auth.uid == userId; } ``` - New projects start in locked mode; keep production locked-by-default and open specific paths deliberately. Test with the Rules Playground and emulator before deploying. - Server-side credentials (service accounts for the Admin SDK) bypass rules entirely; they stay on servers only, handled per [secrets.md](secrets.md). ## Supabase - Enable RLS on **every** table exposed through the API, then write policies; a table without RLS is readable and writable with the public `anon` key: ```sql alter table profiles enable row level security; create policy "own rows" on profiles for select using ( auth.uid() = user_id ); ``` Write separate policies per operation (`select`, `insert`, `update`, `delete`); no policy means no access once RLS is on, which is the correct starting point. - The `service_role` key bypasses RLS; it is a server-only secret that must never reach the client bundle or the repository. - Supabase Auth supports MFA (TOTP on every plan). Enrolment alone changes nothing: enforce it in your policies by requiring the `aal2` assurance level, so a session that has not completed the second factor cannot read protected rows ([mfa.md](mfa.md) for the general rules): ```sql create policy "mfa required" on profiles as restrictive to authenticated using ((select auth.jwt()->>'aal') = 'aal2'); ``` ## Verify - With only the public key (no signed-in user), API reads and writes against protected tables/paths fail. - Signed in as user A, reading user B's rows fails. - Search the client bundle for `service_role` and private keys; the result must be empty. ## Sources (checked September 2026) - Firebase security rules: https://firebase.google.com/docs/rules - Supabase row level security: https://supabase.com/docs/guides/database/postgres/row-level-security - Supabase multi-factor authentication (aal1, aal2, enforcement policy): https://supabase.com/docs/guides/auth/auth-mfa ====================================================================== ==> web-exposure.md ====================================================================== # Files a web server must never serve: dotfiles, .git, dumps, backups, and client secrets Scanners request `/.env`, `/.git/config`, `/config.php.bak`, and `/db.sql` continuously, and any of these under a web root hands over credentials or source regardless of what the application itself authenticates. Anything a deploy step leaves inside the document root is served too, unless the server is told otherwise. ## nginx Deny dotfiles by regex location, with the ACME challenge path carved out first, because `.well-known` also starts with a dot: ```nginx location ^~ /.well-known/acme-challenge/ { allow all; } location ~ /\. { deny all; } ``` Once nginx picks the `^~` location as the longest matching prefix, it skips regex locations entirely, so the challenge path is served before the dotfile deny is reached (`allow`/`deny`: `ngx_http_access_module`; `location` order and `^~`: `ngx_http_core_module`). ## Apache ```apache Require all denied Require all denied ``` `` matches the request's basename, not its full path, so it alone does not catch `/.git/config`: the matched name is `config`, which does not start with a dot (per the core module documentation). The `` rule above matches any dot-prefixed path segment (`.git`, `.svn`, and similar) as a substring of the filesystem path, because Apache's regex is not anchored unless the pattern itself anchors it (per the core module documentation). A bare `(?!well-known)` lookahead only rules out that literal substring, so it would still allow a directory that merely starts with "well-known", such as `/.well-known-backup/config`; the `(?:/|$)` boundary requires the exempted segment to be `well-known` exactly, ending at a slash or the path's end, so only the real ACME challenge directory is exempt. Keep the `` rule as a backstop for `.sql`, `.dump`, and `.bak` basenames and for top-level dotfiles like `/.env`. ## Caddy ```caddyfile respond /.git/* 404 respond /.env 404 respond /.env.* 404 ``` If a broader matcher replaces this list, carve out `/.well-known/*` first: legitimate things live there (ACME challenges, `security.txt`), and Caddy already serves its own ACME challenges outside the file server. ## Keep dumps and backups out of the served directory `.sql`, `.dump`, and backup archives should never land inside a directory a web root points at; the deny rules above are a backstop, not the control. Write exports and backups outside the document root, or to object storage ([object-storage.md](object-storage.md)), never `/var/www/html`. ## Client bundles: secrets compiled into the browser `NEXT_PUBLIC_`-prefixed variables in Next.js and `VITE_`-prefixed variables in Vite are inlined into the browser JavaScript at build time; Create React App's `REACT_APP_` prefix does the same (Create React App is deprecated as of this writing, but the convention persists in older projects). An LLM provider key pasted into frontend code under one of these prefixes ships to every visitor's browser, not just your server. Only genuinely public values belong behind these prefixes; call the provider from a backend route and keep the key server-side. See [secrets.md](secrets.md) and [paas.md](paas.md) for the platform version of this. A production source map (`.map` files, or an inline `//# sourceMappingURL` comment) reconstructs original source too; do not ship one to a public origin for code not already meant to be public. ## Verify ```bash curl -s -o /dev/null -w '%{http_code}\n' https://example.com/.env curl -s -o /dev/null -w '%{http_code}\n' https://example.com/.git/config curl -s -o /dev/null -w '%{http_code}\n' https://example.com/config.php.bak curl -s -o /dev/null -w '%{http_code}\n' https://example.com/db.sql # each line above must print 403 or 404, never the file's content curl -s -o /dev/null -w '%{http_code}\n' https://example.com/.well-known/acme-challenge/x # must not be 403 or 404; the DirectoryMatch exemption must still let the real ACME # challenge path through curl -s -o /dev/null -w '%{http_code}\n' https://example.com/.well-known-backup/config # must be 403 or 404; a directory name that only starts with "well-known" must not # inherit that exemption grep -rn "sk-\|AKIA\|-----BEGIN" .next/static build dist 2>/dev/null # must print nothing when run against the built client bundle, not the source; # a variable that never compiled in cannot leak ``` Any backup path known to have existed on the server should also 404 at the deployed URL. ## Sources (checked September 2026) - nginx core module (`location`, `^~` modifier, matching order): https://nginx.org/en/docs/http/ngx_http_core_module.html - nginx access module (`allow`, `deny`): https://nginx.org/en/docs/http/ngx_http_access_module.html - Apache mod_authz_core (`Require all denied`): https://httpd.apache.org/docs/2.4/mod/mod_authz_core.html - Apache core module (``, ``): https://httpd.apache.org/docs/2.4/mod/core.html#filesmatch, https://httpd.apache.org/docs/2.4/mod/core.html#directorymatch - Caddy `respond` directive: https://caddyserver.com/docs/caddyfile/directives/respond - Caddy matchers: https://caddyserver.com/docs/caddyfile/matchers - Next.js environment variables (`NEXT_PUBLIC_`): https://nextjs.org/docs/pages/guides/environment-variables - Vite env variables (`VITE_`): https://vite.dev/guide/env-and-mode - Create React App environment variables (`REACT_APP_`, deprecation notice): https://create-react-app.dev/docs/adding-custom-environment-variables/ ====================================================================== ==> realtime-webhooks.md ====================================================================== # WebSockets, server-sent events, and webhooks: authenticating the non-page endpoints Streaming an LLM response commonly rides a WebSocket or an SSE stream, and integrations commonly call back through a webhook. All three are transports, not authentication mechanisms, and each is routinely shipped wide open. [authentication.md](authentication.md) rule 15 already says each transport needs its own check; this guide is that check for these three. ## 1. WebSocket authentication The browser `WebSocket()` constructor takes only a URL and an optional `protocols` list; it has no argument for request headers, so a page cannot attach `Authorization: Bearer ...` to the handshake (per MDN's WebSocket API reference). Two verified patterns fill the gap: - **Cookie plus Origin check.** The handshake is still an HTTP request, so a session cookie the server already set is sent automatically. That alone is not enough: any page on any origin can open a WebSocket to your endpoint and ride the visitor's cookie, which is cross-site WebSocket hijacking (CSWSH). OWASP's WebSocket Security Cheat Sheet is explicit: validate the `Origin` header on every handshake against an allowlist, not a denylist, since wildcard or substring matching is error-prone. Reject the upgrade server-side before any application logic runs if `Origin` is missing or not on the list. - **A token in the first message after the socket opens.** OWASP recommends token-based authentication as the stronger option for exactly this case: pass a short-lived token as a message right after the socket opens, verify it before treating the connection as authenticated, and rotate tokens on long-lived connections. Keep the token out of the URL query string and out of `Sec-WebSocket-Protocol` (the `protocols` argument, meant for subprotocol negotiation, not credentials); both are more likely than a message payload to end up in access logs or proxy logs. Do both where you can: Origin validation stops the hijack, and a token means a stolen cookie alone is not a working credential against the socket. ## 2. Server-sent events (SSE) An `EventSource` is a plain HTTP GET (confirmed on MDN); it carries cookies the same way any GET does, and `withCredentials: true` sends them cross-origin too. `EventSource` cannot set an `Authorization` header either, so a token-in-header scheme does not reach it. That leaves the same obligations as any authenticated GET: the server must still require the session cookie, and because the browser will happily fire that GET from a page on another origin, checking Origin (and treating the endpoint like a CSRF-relevant one) applies here as much as it does to a WebSocket. If a client needs a header-based token, use `fetch()` with a `ReadableStream` reader instead of `EventSource`. ## 3. Webhooks: verify the sender, not just the shape of the payload A webhook has no session and no browser to enforce Origin, so the check is a signature over the raw request body: - **GitHub** signs deliveries with `X-Hub-Signature-256`: an HMAC-SHA256 hex digest of the payload using your webhook's secret, prefixed `sha256=`. Recompute it yourself and compare with a constant-time function (`crypto.timingSafeEqual` in Node.js, `secure_compare` in Ruby); a plain `==` leaks timing. - **Stripe** signs events with `Stripe-Signature`, formatted `t=,v1=`. The signed payload is the timestamp concatenated with `.` and the raw body, HMAC-SHA256 with the endpoint's `whsec_...` secret. Verify with an official library where one exists; reject if the timestamp is older than your tolerance (Stripe's libraries default to 5 minutes, and Stripe warns never to set the tolerance to 0, which disables the recency check entirely). Apply the shared lesson everywhere: use a distinct secret per endpoint (rotating one does not break every integration at once), and reject a request whose signature does not match before your handler touches it. Replay handling itself is provider specific, not a single universal timestamp check. GitHub's signed payload carries no timestamp, and `X-GitHub-Delivery` is not part of what the signature covers: `X-Hub-Signature-256` is computed over the raw body alone (per GitHub's guidance on validating webhook deliveries), so a captured signed request replayed with a different or reused delivery id still produces a valid signature. Tracking `X-GitHub-Delivery` and refusing a delivery id you have already handled only catches GitHub's own retries of the same delivery; it is not replay protection. For genuine replay protection, make handling idempotent against data the authenticated payload itself carries (an event id or resource state field), or store a digest of the verified body with a retention window and refuse to reprocess a digest already seen, or lean on a provider whose signature already covers a timestamp. Stripe signs a timestamp as part of the signed payload, so reject a request whose timestamp is older than your tolerance window, and never set that tolerance to zero, which disables the recency check entirely. Both providers require the exact raw bytes; a framework that parses and re-serializes JSON before your verification code runs will break the check, so verify signatures against the body as received. Where a webhook route must be exempt from a site-wide CSRF filter, scope that exemption to that one route and method only, never to a whole controller or prefix. ## 4. Bound the expensive endpoints too Inference, upload, and job-submission endpoints are usually the ones behind these transports, and an authenticated caller can still exhaust them. Two layers need separate limits. At the proxy in front of the app: a maximum request or body size, a connection concurrency cap per client, and a request timeout matched to realistic response time bound the HTTP connection itself; this repository's nginx, Caddy, and Traefik guides do not yet carry those specific directives, so set them directly from each proxy's own documentation. After a WebSocket upgrade, a single accepted connection can still carry unlimited messages or job submissions, which the connection-level limits above do not touch; OWASP's WebSocket Security Cheat Sheet calls for message-level authorization (checking that each individual message is allowed, not only the handshake) and message-level rate limiting (capping messages per connection per time window) as the separate control that applies once the socket is open. [authentication.md](authentication.md) covers the identity side these limits key off. ## Verify ```bash # Handshake-time auth (a cookie or Origin gate at the proxy or app): rejected before the upgrade completes curl -i --http1.1 -H "Connection: Upgrade" -H "Upgrade: websocket" \ -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" \ https://app.example.com/ws # expect 401/403, not 101 # Post-connect auth (first-message token): the handshake itself succeeds, so this is # not a curl-only test. Open the socket with a WebSocket client and send no token: # expect a 101 upgrade, then no protected data and a close once the grace period for # the first message elapses, proving the server withholds data until the token lands. # Cross-origin handshake: rejected on Origin even with a valid cookie curl -i --http1.1 -H "Connection: Upgrade" -H "Upgrade: websocket" -H "Origin: https://evil.example" \ -H "Sec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==" -H "Sec-WebSocket-Version: 13" \ -b "session=REPLACE_WITH_VALID_SESSION_COOKIE" https://app.example.com/ws # expect 403 curl -i https://app.example.com/events # SSE without a session: 401/403, not text/event-stream curl -i -X POST https://app.example.com/webhooks/provider \ -H "X-Hub-Signature-256: sha256=0000000000000000000000000000000000000000000000000000000000000000" \ -d '{"test":true}' # expect 401, wrong signature rejected ``` ## Common mistakes - Treating the page login as coverage for the WebSocket or SSE endpoint it opens; each needs its own check. - Comparing webhook signatures with `==` instead of a constant-time comparison. - Letting a framework's body parser touch the request before webhook signature verification runs, which breaks the raw-body check. ## Sources (checked September 2026) - MDN: WebSocket API, `WebSocket()` constructor (no header support, `protocols` argument): https://developer.mozilla.org/en-US/docs/Web/API/WebSocket/WebSocket - MDN: Using server-sent events (`EventSource`, plain GET, `withCredentials`): https://developer.mozilla.org/en-US/docs/Web/API/Server-sent_events/Using_server-sent_events - OWASP WebSocket Security Cheat Sheet (Origin allowlisting, token-based authentication): https://cheatsheetseries.owasp.org/cheatsheets/WebSocket_Security_Cheat_Sheet.html - GitHub: Validating webhook deliveries (`X-Hub-Signature-256`, constant-time comparison): https://docs.github.com/en/webhooks/using-webhooks/validating-webhook-deliveries - Stripe: Verify webhook signatures (`Stripe-Signature`, timestamp tolerance, official libraries): https://docs.stripe.com/webhooks#verify-official-libraries ====================================================================== ==> bi-dashboards.md ====================================================================== # BI dashboards: Metabase, Superset, Redash These tools hold live connections to your production databases and cache query results in their own storage. An exposed instance, or one still running default or example credentials, leaks both the dashboards themselves and the databases behind them. One rule dominates everything tool-specific below, the same as [admin-uis.md](admin-uis.md): **never public.** Reach it over SSH port forwarding, a tailnet ([tailscale.md](tailscale.md)), or Cloudflare Access ([cloudflare.md](cloudflare.md)), with MFA enforced at that fronting layer ([mfa.md](mfa.md)), and connect it to the database with a least-privilege, read-only account wherever the dashboards do not need to write back. ## Metabase The first account created during setup becomes the admin account, so an instance reachable on the network before you finish the setup wizard lets whoever gets there first claim admin. Complete setup before the instance is reachable from anywhere but you. Public links and public embeds are enabled by default and let admins share a question, dashboard, or document with anyone holding the URL; visitors get view-only results with no login. Metabase's own docs warn that the public link URL is recoverable from a public embed, so an embed is not a stronger boundary than a plain public link. Row and column security (per-user sandboxing of the underlying data) is a Pro/Enterprise feature, not available on the open-source edition, so on the free tier a public link or a shared question exposes whatever rows and columns the question already queries. Disable public sharing in Admin Settings unless you specifically need it, and treat every public link as a permanent, unauthenticated data release. ## Apache Superset Superset ships a `SECRET_KEY` that signs session cookies; its own docs call it "very important to keep the `SECRET_KEY` secret and set to a secure unique complex random value." Set it, and change the admin password created at first run, before the instance is reachable by anyone else. Anonymous visitors are assigned the Public role only when `AUTH_ROLE_PUBLIC` is configured; leave it unset unless you intend anonymous dashboard access, and if you do, scope that role's permissions with `PUBLIC_ROLE_LIKE` rather than granting it broadly. Superset's own `docker-compose.yml` states plainly that the stack is not supported for production and that a real deployment needs its own environment file with unique passwords and `SECRET_KEY`; do not run the example or dev compose file against a production database. ## Redash Setup creates the admin account on first run: "it will ask you to create your admin account. Once this is done, you can start using Redash." Redash's own setup guidance calls out HTTPS as something you add, not something it does for you: "If this is a production setup, you should enforce HTTPS." Front it the same as the other tools here rather than relying on anything Redash provides natively. ## Verify ```bash ss -tlnp | grep -E ':3000|:8088|:5000' # Metabase / Superset / Redash bound to loopback only, ports vary by install curl -sI https://bi.example.com/ # 401/403 or a login redirect, never a dashboard ``` Confirm from outside your network that each tool's URL never renders a dashboard without a login, that no default admin/admin or example credentials still work, and that the database role each tool connects with is read-only where the dashboards do not need write access; check the connection string or data source config, not just the tool's own login. ## Sources (checked September 2026) - Metabase documentation home: https://www.metabase.com/docs/latest/ - Metabase setting up Metabase (first account is admin): https://www.metabase.com/docs/latest/configuring-metabase/setting-up-metabase - Metabase public links and embeds: https://www.metabase.com/docs/latest/embedding/public-links - Metabase data permissions (row and column security is Pro/Enterprise): https://www.metabase.com/docs/latest/permissions/data - Superset security: https://superset.apache.org/admin-docs/security/ - Superset docker-compose.yml (production warning): https://github.com/apache/superset/blob/master/docker-compose.yml - Redash help center: https://redash.io/help/ - Redash setting up a Redash instance: https://redash.io/help/open-source/setup/ ====================================================================== ==> pocketbase.md ====================================================================== # Self-hosted backends: PocketBase and Appwrite Like Firebase and Supabase ([firebase-supabase.md](firebase-supabase.md)), these backends hand a public API endpoint to your client code; the collection or resource rules you write are the only gate between the internet and your data. Both also ship an admin console whose first user becomes the operator account, so that bootstrap step has to happen before the instance is reachable by anyone else. ## PocketBase Create the superuser before opening access. The console command is `./pocketbase superuser create EMAIL PASS`; the alternative is the web-based installer linked from the server's own startup log. Do not leave a fresh instance open to the network while that account is still unclaimed. PocketBase has no native TLS listener beyond its own ACME integration: run `./pocketbase serve yourdomain.com` and it issues and renews a Let's Encrypt certificate for that domain automatically, or put it behind your own reverse proxy per [nginx.md](nginx.md) or [caddy.md](caddy.md) and terminate TLS there instead. PocketBase 0.38 and later can restrict superuser sessions by IP: set the allowed list under Settings > Application > Superuser IPs, or from the console with `./pocketbase superuser ips 127.0.0.1 10.0.0.0 --dir=/path/to/your/pb_data`. Superuser MFA is a separate setting: open the `_superusers` collection and enable its MFA and OTP options there, which adds an email-delivered one-time code requirement when authenticating as a superuser. Both are worth enabling for any instance reachable beyond your own machine. None of that replaces the actual access control: every collection's API rules (`listRule`, `viewRule`, `createRule`, `updateRule`, `deleteRule`) decide what non-superusers can do. A rule left `null` is "locked", meaning only a superuser can perform that action; an empty string opens the action to everyone, including unauthenticated guests. Superusers bypass API rules entirely, so never hand a superuser account or token to a client application; use collection rules and scoped auth for that. ## Appwrite (self-hosted) Enforce HTTPS in production with `_APP_OPTIONS_FORCE_HTTPS`; Appwrite's own docs say to "always prefer HTTPS over HTTP in production environments." Front it per [nginx.md](nginx.md) or [caddy.md](caddy.md) and [fronting-auth.md](fronting-auth.md) if you are not terminating TLS at Appwrite itself. By default only the first user can register through the console; every account after that has to be invited. `_APP_CONSOLE_WHITELIST_ROOT`, `_APP_CONSOLE_WHITELIST_EMAILS`, and `_APP_CONSOLE_WHITELIST_IPS` narrow who can create a console account (the first restricts self-registration to that one first user; the other two add an email or IP allowlist to registration), not who can reach or log into the console dashboard. If the dashboard itself needs to stay unreachable from the open internet, put a separate network or access boundary in front of it, such as a firewall rule, VPN, or the reverse-proxy controls in [caddy.md](caddy.md) or [fronting-auth.md](fronting-auth.md). Project API keys are scoped rather than all-or-nothing; grant only the scopes a given key needs, and treat any key with `keys.write` as equivalent to an admin credential, since it can change or delete other keys' scopes. Keys are meant for server SDKs and CLI use, never for client-side code; store them the way [secrets.md](secrets.md) describes, not in the repository or the client bundle. ## Verify ```bash curl -sI https://backend.example.com/_/ # PocketBase admin UI: login page, not a dashboard curl -sI https://backend.example.com/console # Appwrite console: login page or 401, no open signup form ``` After bootstrap, confirm the console no longer offers public signup (only invitation), that a collection or resource with no rule set denies every request from a non-admin client, and that the client bundle carries only an end-user session or auth token, never a PocketBase superuser token or an Appwrite API key of any scope; Appwrite API keys are a server credential regardless of scope, and client code should authenticate with an end-user session from the client SDK instead. Grep the client bundle for the string used by your admin credentials or API keys; it should not appear. ## Sources (checked September 2026) - PocketBase going to production: https://pocketbase.io/docs/going-to-production/ - PocketBase API rules and filters: https://pocketbase.io/docs/api-rules-and-filters/ - Appwrite self-hosting production security: https://appwrite.io/docs/advanced/self-hosting/production/security - Appwrite project API keys: https://appwrite.io/docs/advanced/platform/api-keys ====================================================================== ==> common-mistakes.md ====================================================================== # Common mistakes The recurring findings behind exposed AI-assisted projects, distilled from the guides in this repository. Each line links to the fix. 1. **Binding to `0.0.0.0` to fix a connection problem** and never binding back. Loopback is the default posture; expose only through a TLS-terminating, authenticated layer. ([README](README.md)) 2. **Publishing Docker ports and trusting UFW.** Published container ports bypass UFW's rules entirely; `ufw deny 3000` does not protect `-p 3000:3000`. ([docker.md](docker.md)) 3. **Default credentials left in place**: mongo-express `admin`/`pass`, Grafana `admin`/`admin`, RabbitMQ `guest`, MinIO `minioadmin`. Scanners try these first. ([admin-uis.md](admin-uis.md), [rabbitmq.md](rabbitmq.md), [minio.md](minio.md)) 4. **`OLLAMA_HOST=0.0.0.0`** on a machine with a public interface: the full model API with no authentication and no TLS. ([ollama.md](ollama.md)) 5. **Disabling TLS verification in clients** (`verify=False`, `rejectUnauthorized: false`, `curl -k`, `NODE_TLS_REJECT_UNAUTHORIZED=0`) instead of distributing trust for a self-signed certificate. ([self-signed.md](self-signed.md)) 6. **Secrets committed to the repository**, baked into images, or printed to logs, then "deleted" instead of rotated. ([secrets.md](secrets.md)) 7. **Auth on the home page but not the API.** `/` redirects to a login while `/api/...` serves data unauthenticated. Test the API paths. ([authentication.md](authentication.md)) 8. **Plain HTTP still serving next to HTTPS** instead of redirecting, leaving credentials to cross in cleartext on the forgotten port. (Every server guide's redirect step.) 9. **Quick tunnels left running**: `trycloudflare.com` URLs and Gradio `share=True` links are unauthenticated publication, not deployment. ([cloudflare.md](cloudflare.md), [gradio.md](gradio.md)) 10. **Security features switched off to silence errors**: `xpack.security.enabled: false`, MongoDB without `authorization: enabled`, Redis with an empty `requirepass`. The error was the protection. ([elasticsearch.md](elasticsearch.md), [mongodb.md](mongodb.md), [redis.md](redis.md)) 11. **`Access-Control-Allow-Origin: *` on endpoints that use cookies or keys**, or reflecting whatever Origin arrives. ([cors.md](cors.md)) 12. **Database ports open to `0.0.0.0/0` in cloud firewalls** because a remote client needed access once. ([cloud-firewalls.md](cloud-firewalls.md)) 13. **Firebase or Supabase rules left open** (`allow read, write: if true;`, RLS disabled) because the client key "worked": the key is public by design, the rules are the security. ([firebase-supabase.md](firebase-supabase.md)) 14. **Single-factor logins on human-facing services** when the stack or a fronting layer supports MFA. ([mfa.md](mfa.md)) 15. **"Signed in with Google" treated as authorised.** Any Google or Microsoft account passes the login and nothing checks the domain, tenant, or group. ([oidc-integration.md](oidc-integration.md)) 16. **MCP servers on all interfaces.** An HTTP MCP server bound to `0.0.0.0` with no token or OAuth hands every tool it exposes to the network. ([mcp-servers.md](mcp-servers.md)) 17. **Unauthenticated AI infrastructure**: Ray dashboards, MLflow trackers, and vector databases with authentication off. ([ray.md](ray.md), [mlflow.md](mlflow.md), [vector-databases.md](vector-databases.md)) 18. **MFA enrolled but not enforced**: users have a second factor and a password-only session still works. ([authentication.md](authentication.md)) 19. **A retired component kept because it still runs.** ingress-nginx stopped receiving security patches in March 2026; a controller that still routes traffic is not a controller that is still safe. ([kubernetes.md](kubernetes.md)) Run the [README verification checklist](README.md#verification-checklist) after any fix; several of these only surface when tested from outside the host.