Protect an app
Wall an app off behind DavidApps, then choose how people and automation get in.
Walling an app off puts the decision in front of it. Every request to the app's hostname is checked first, and the app only ever sees the ones that were allowed. The app needs no sign-in screen, no user table, and no library from us — an unmodified container behind these rules is protected.
Access is bound to one exact hostname. A session for echo.davidapps.dev is
not a session for anything else, and there are no wildcards.
Copy the addressing rules
Open the app in the dashboard, and its Overview screen shows the exact block below with a copy button. Paste it into that app's own deployment. Copying it changes nothing on its own, and nothing is applied for you.
# Echo — paste into values.ingress of this app's release.
# Create this app-namespace ExternalName bridge; Kubernetes Ingress cannot
# target the auth namespace directly. Every reserved path below names it.
gateService:
name: davidapps-gate
type: ExternalName
externalName: da-gate.auth.svc.cluster.local
port: 8080
gate:
className: external
backendService: davidapps-gate:8080
annotations:
external-dns.alpha.kubernetes.io/target: external.davidapps.dev
nginx.ingress.kubernetes.io/proxy-body-size: "4k"
nginx.ingress.kubernetes.io/client-body-buffer-size: "4k"
nginx.ingress.kubernetes.io/proxy-connect-timeout: "5"
nginx.ingress.kubernetes.io/proxy-read-timeout: "15"
nginx.ingress.kubernetes.io/proxy-send-timeout: "15"
nginx.ingress.kubernetes.io/proxy-buffer-size: "8k"
nginx.ingress.kubernetes.io/proxy-buffers-number: "4"
nginx.ingress.kubernetes.io/proxy-set-headers: auth/davidapps-empty-trusted-headers
stripRequestHeaders:
- X-DA-Assertion
- X-DA-User
- X-DA-Email
- X-DA-Name
- X-DA-Kind
- X-DA-Rol
- X-DA-Epoch
- X-DA-Tid
- X-Original-Method
- X-Original-URL
- X-Original-Host
- X-Davidapps-Protection-Bypass
- X-Davidapps-Set-Bypass-Cookie
hosts:
- host: echo.davidapps.dev
paths:
- path: /_da/gate/start
pathType: Exact
upstreamPath: /start
backend:
service:
name: davidapps-gate
port: 8080
- path: /_da/callback
pathType: Exact
upstreamPath: /callback
backend:
service:
name: davidapps-gate
port: 8080
- path: /_da/gate/password
pathType: Exact
upstreamPath: /_da/gate/password
backend:
service:
name: davidapps-gate
port: 8080
- path: /_da/gate/redeem
pathType: Exact
upstreamPath: /_da/gate/redeem
backend:
service:
name: davidapps-gate
port: 8080
- path: /_da/gate/bypass
pathType: Exact
upstreamPath: /_da/gate/bypass
backend:
service:
name: davidapps-gate
port: 8080
tls:
- hosts:
- echo.davidapps.dev
gated:
className: external
annotations:
external-dns.alpha.kubernetes.io/target: external.davidapps.dev
nginx.ingress.kubernetes.io/auth-url: http://da-gate.auth.svc.cluster.local/gate/check
nginx.ingress.kubernetes.io/auth-signin: https://$host/_da/gate/start
nginx.ingress.kubernetes.io/auth-signin-redirect-param: rd
nginx.ingress.kubernetes.io/auth-always-set-cookie: "true"
nginx.ingress.kubernetes.io/auth-cache-duration: "200 202 0s, 401 0s"
nginx.ingress.kubernetes.io/auth-response-headers: >-
X-DA-Assertion,X-DA-User,X-DA-Email,X-DA-Name,X-DA-Kind,X-DA-Rol,X-DA-Epoch,X-DA-Tid
hosts:
- host: echo.davidapps.dev
paths:
- path: /
pathType: Prefix
tls:
- hosts:
- echo.davidapps.dev
# Paths that must stay open. They carry no annotations on purpose.
open:
className: external
annotations:
external-dns.alpha.kubernetes.io/target: external.davidapps.dev
nginx.ingress.kubernetes.io/proxy-set-headers: auth/davidapps-empty-trusted-headers
hosts:
- host: echo.davidapps.dev
paths:
# the whole body is read before a 401
- path: /api/upload
pathType: Prefix
# one decision would cover the stream's life
- path: /api/stream
pathType: Prefix
# an upgraded socket is never re-checked
- path: /ws
pathType: Prefix
# this arrives with no cookies
- path: /_da/logout
pathType: Exact
# a webhook sender cannot sign in
- path: /api/webhooks
pathType: Prefix
tls:
- hosts:
- echo.davidapps.devThe block is generated from the app's stored hostnames, so it stays in step with the dashboard. An address that is not a plain hostname is dropped rather than pasted into your YAML, and the screen tells you when that happened.
On uk-cluster, paste the Envoy Gateway SecurityPolicy from the same
Overview screen (or from get_ingress_snippet, which now returns envoy
alongside the nginx YAML). Point backendRefs at the DavidApps Service that
runs on that cluster. The production-cluster DNS name from the nginx block
does not exist there.
A second checker on home-cluster or uk-cluster is the same binary with a
unique replica id, the shared HMAC key, the public signing URL pinned to
https://id.davidapps.dev/api/auth/jwks, and the poll URL at
https://id.davidapps.dev/internal/revocations. It still loads authorization
from the identity Postgres. Login always happens on id.davidapps.dev.
Checkers do not talk to each other; each refreshes its own snapshot.
The bridge, and why it is there
gateService comes first and is the piece people skip. It is an ExternalName
alias, created in your app's own namespace, that resolves to where DavidApps
actually runs. An addressing rule can only send traffic to a service standing
beside it in the same namespace; it cannot reach across into the auth
namespace on its own. The alias is what makes that reachable, and every
reserved path below names the alias rather than a foreign address.
The five reserved paths
DavidApps needs a handful of endpoints on your app's own hostname, and those five go straight through the bridge with no check in front of them — a check would refuse the very requests that exist to get somebody past it. They are fixed, and there are exactly five:
/_da/gate/start show a sign-in
/_da/callback finish one and set the session
/_da/gate/password the shared-password form
/_da/gate/redeem spend a share link
/_da/gate/bypass present a bypass tokenEverything else on the hostname is checked. Requests to these five are held to their own limits rather than your app's: a 4k body and a 4k client-body buffer, five seconds to connect, fifteen seconds each to read and to send, and four 8k buffers for the response.
The sign-in destination is host-local — it is built from the hostname of the request itself, so a refused request is sent to the start path on the very address it asked for. The browser stays on the protected app's hostname throughout, which is what lets the session cookie be set for that host and only that host.
Headers, in both directions
auth-signin-redirect-param: rd carries the address someone was trying to
reach, so they land back on it instead of on a home page.
auth-cache-duration: "200 202 0s, 401 0s" turns caching off in both
directions on purpose: a cached allow is a stranger's session, and a cached
refusal locks out someone who has just been let in.
Traffic that does not go through the check still does not get to arrive
pre-trusted. The reserved paths name their strip list in full, as
stripRequestHeaders: the eight identity headers, the three X-Original-*
headers that describe the original request, and both X-Davidapps-* bypass
headers. The open paths clear the same set through the shared
auth/davidapps-empty-trusted-headers configuration. A visitor can send any of
those names; none of them survives the trip, so none can arrive looking like
something DavidApps produced.
Coming back the other way, the auth-response-headers list is exhaustive by
design. Named headers are overwritten with the values DavidApps produced;
anything not named would pass through exactly as the browser sent it, so an
omission from this list is a header a visitor could forge.
The app receives these, and only these:
X-DA-User who this is, as an ID that is stable for this app
X-DA-Email their email address
X-DA-Name their display name
X-DA-Kind how they got in: user, visitor, password, link, or bypass
X-DA-Rol the role this app's access rules resolved for them
X-DA-Tid which app this decision was for, by its short name
X-DA-Epoch which revision of their access this decision was made against
X-DA-Assertion a signed copy of all of the above, for apps that verify itGroups are not in that list, and there is no header or signed claim that names
them. A person's groups can decide whether they are let in at all, and they can
decide which role comes through in X-DA-Rol, but the app is told the answer
rather than the reasoning. The reasoning is readable on the app's Access
screen, not in front of the app itself — so build on the role, not on a group
name the app was never sent.
An app may trust the plain headers because it can only be reached through DavidApps. The signed one is the real boundary, and it is what Auto-login verifies.
Choose how people get in
An app can offer more than one of these at once.
Sign in with DavidApps
The person signs in with Google, GitHub, Discord, or a magic link on that app's own branded sign-in page, then the app's access rules decide the rest: who they are, which groups they are in, and whether anything denies them. See Groups and access.
Shared password
One password for the whole app, useful for something small and private that does not need accounts. Anyone reaching the app is shown a plain form, and a correct password gets them a session for that hostname only.
Attempts are limited to five a minute from one client address for one app, the form carries a one-use token that expires after ten minutes, and the destination someone was heading for travels with the form rather than being chosen by whoever submits it. Rotating the password invalidates every session that password created, because the session is bound to the stored secret and not just to the app.
Share link
A signed link a visitor redeems for a session on that app's hostname. Each redemption creates one session; whether the same link works again is up to the limits you put on it — an expiry, a maximum number of uses, or both — and it can be turned off on its own at any time without touching anyone else's access. Redemption is atomic, so a link with one use left cannot be spent twice by two people at the same moment.
Bypass token for CI
For scripts and build jobs that cannot sign in. Send the token as a header:
GET /health HTTP/1.1
Host: echo.davidapps.dev
X-Davidapps-Protection-Bypass: dab1.<grant>.<secret>That authorizes the one request and returns no cookie, which is what you want
for a job that makes a handful of calls. If an automated browser needs to keep
a session across several requests, add X-Davidapps-Set-Bypass-Cookie: true
(or samesitenone when the flow is cross-site) and it will receive one.
A bypass token stands in for a person, so treat it as the credential it is: give one per job, rotate it on the same schedule you would a password, and keep it in the header rather than in a query string. The header is checked against the stored hash on every request, and there is no separate limit on how often a job may present it.
You can create share links through the HTTP API and the MCP tools. Shared passwords, share links, and bypass tokens all appear on the app's Access screen, where you can see how often each has been used and turn any of them off. Rotating one issues a new secret and retires the old one in the same step.
Requests that are not page loads
Every refusal is the same answer: 401 with one small fixed JSON body.
Turning that into a sign-in redirect is the front door's job, which is what
auth-signin in the snippet above configures — and it applies to every refused
request, including a fetch from your own JavaScript. A fetch that follows
that redirect ends up parsing a sign-in page as JSON.
If that matters to your app, answer refusals differently by request shape at
your own front door: return the 401 to anything that asks for JSON or is not
a top-level navigation, and redirect only real page loads. The
acceptance harness in deploy/local/phase1 does exactly that and is the
working reference for the configuration; it is not part of the block the
dashboard hands you.
Paths that must stay open
The last block in the snippet carries no check, which is what makes those five paths open. They are your app's paths, not DavidApps' own, and each one is there for its own reason — the reasons are in the comments: an upload is fully read before a refusal can be returned, one decision would otherwise cover a long-lived stream for its entire life, an upgraded socket is never re-checked, a back-channel sign-out arrives with no cookies at all, and a webhook sender has no way to sign in. They still have the trusted headers stripped on the way in, so being open is not the same as being trusted.
Open means open. Anything you put behind these prefixes is reachable by anybody, so keep them narrow and make sure the app authenticates them itself where that matters.
Browser preflight is handled separately: an app can carry a list of path
prefixes whose OPTIONS requests are answered with 204 and no identity work
at all, so a cross-origin call can complete its preflight without a session.
Ordinary requests to those same paths are still checked normally. The list is
stored per app and is currently set outside the dashboard.
When something is wrong
Every failure resolves to the same refusal. A missing, expired, malformed, or
wrong-hostname session, an unreadable request, and an internal fault all return
the identical 401, so nothing about the outcome tells a stranger which one
happened.
If the access data DavidApps holds cannot be refreshed and goes stale, it stops allowing anything rather than serving decisions it can no longer stand behind. That is deliberate: a protected app that lets everyone in during an outage is worse than one that is briefly unavailable.
Sessions renew themselves while they are being used, so an active person is not interrupted, and a revoked one stops working within a minute.
Keep secrets out of addresses
Send a bypass token as a header whenever the client can set headers. An
ordinary request to one of the app's own URLs is refused outright, rather than
honoured, if it carries _da_share or x-davidapps-protection-bypass as a
query parameter. Those parameters buy nothing on a normal address, and a
request that arrives with one is turned away.
Spending a share token has its own address, and that route is the deliberate exception. Creating a share link hands you the token itself, not a link; the address that spends it is built on the protected app's own hostname:
https://echo.davidapps.dev/_da/gate/redeem?share=<token>&next=/Both parts are required. next has to be a path on the same app, and anything
else is refused rather than followed. Opening that address exchanges the token
for a session on that hostname and sends the browser onward to next with a
303. The token is not carried into that onward address, and the
response is sent with Referrer-Policy: no-referrer, so the address that spent
it is not passed on as a referrer either.
What that does not do is keep the redemption address itself out of the way. The token is in the address bar for that one request, and the browser may keep it in history — so treat a share link as the credential it is. Give it an expiry, keep the number of uses low, send it to one person, and turn it off on the app's Access screen once it has done its job. Until one of those limits stops it, anyone who can read that address can redeem it again.
Where this stands
The block above is correct for the service it names, and that service is not running yet. No production deployment has been made from this repository, so treat this page as the integration contract rather than as something to point a live hostname at today.