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.dev

The 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 token

Everything 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 it

Groups 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.

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.

On this page