Apps are public by default. Authentication can protect any static site, HTML app, handler tree, container service, or selected path. Declare a policy in Compose x-kedge, an HTML meta tag, or Markdown front matter; the same fields work in all three.

common policies

Only you:

x-kedge:
  auth: owner

People you invite, each emailed a reusable sign-in link:

x-kedge:
  auth:
    emails: [friend@example.com]

A GitHub organization, gating one HTML page:

<meta name="kedge-auth-github" content="my-org">

policies

field admitted identity
required / app-users: true active app-scoped user
emails: [friend@example.com] exact verified mailbox as an app user
owner active owning Kedge account
google: true any Google identity
google: [person@gmail.com] exact verified Google account
google: [example.com] exact signed Google Workspace hd claim
github: true any GitHub identity
github: [octocat] exact GitHub login or active organization membership

Policy members are ORed. Provider selectors stay flat: a Google mailbox such as person@gmail.com is an account, while example.com (or the legacy @example.com spelling) is a hosted domain. A GitHub value matches the authenticated login when equal and otherwise checks active organization membership.

emails is provider-independent. A matching email link, or the same verified address returned by Google or GitHub, creates an app user. Provider-specific Google accounts and GitHub accounts or organizations remain workforce identities and do not create an app-user record.

auth gates requests. identity leaves the page public and resolves an optional viewer for $verified, $viewer, and authored data. All declarations in one app must admit the same identities.

Compose auth covers every path by default. Auth declared in one HTML or Markdown page defaults to that page's resolved route. Explicit paths use exact matches, parameterized page routes, /**, or /prefix/**, with optional exceptions.

An email suffix or public organization listing is not sufficient. Invalid patterns, identities, fields, or an empty policy fail the deploy. Private GitHub organization membership requires Kedge's verifier to have member access to that organization. Organization owners control that access.

request behavior

Protected browser navigations receive a 303 to /_kedge/auth/login. Protected fetches receive status 401:

{"error":"authentication_required","login_url":"/_kedge/auth/login?next=%2Fadmin"}

The login route always shows the available methods.

Login, denied, and authenticated dynamic responses are private, no-store. Sessions are scoped to one app and exact hostname. App-user sessions have a 30-day absolute and seven-day idle lifetime; workforce sessions last eight hours.

email invitations

After a successful deploy has a URL, Kedge emails each newly added emails recipient. An unchanged recipient is not emailed again on later deploys. The link can be reused while the address remains in the policy.

The login page also accepts an allowed address and sends another reusable link. The response is the same for matching and non-matching addresses. Requests are limited per address and source. A new request does not invalidate older links.

The token is carried in the URL fragment. The app-origin sign-in page removes the fragment, redeems it with a same-origin request, sets the app session, and opens the requested page. A fragment-free GET cannot redeem the token. Removing a recipient revokes its outstanding links and app-user sessions.

Invitation and sign-in delivery uses the configured transactional email sender. Pending messages are stored durably and retried by one fleet leader.

HTML apps

Gate only the page containing this meta element:

<meta name="kedge-auth" content="github">
<h1>Members</h1>

A gated page's generated reads, writes, actions, and live updates also require its admitted viewer.

Use identity when the page itself stays public. This guestbook can be read by anyone, but only GitHub users can post:

<meta name="kedge-identity" content="github">

<a data-when="!$viewer.verified" href="/_kedge/auth/login">sign in to post</a>
<form data-kedge="posts?$verified">
  <textarea name="body" required></textarea>
  <button>post</button>
</form>
<ol data-kedge="posts"><template><li>{{body}}</li></template></ol>

Expanded meta properties do not require embedded JSON:

<meta name="kedge-auth-owner" content="true">
<meta name="kedge-auth-emails" content="friend@example.com, teammate@example.net">
<meta name="kedge-auth-github" content="my-org, another-org">
<meta name="kedge-auth-google" content="person@gmail.com, example.com">
<meta name="kedge-auth-paths" content="/admin/**">
<meta name="kedge-auth-except" content="/admin/health">

Provider lists are trimmed, lowercased, and deduplicated. true admits any identity from that provider. Unknown properties, false, and empty values fail the deploy. identity supports the same identity properties but not paths or except.

HTML apps expose {{$viewer.id}}, name, verified, and owner. $verified and $owner restrict individual bindings. Markdown's :account directive supplies login, identity, and logout controls.

GET /_kedge/auth/me returns {"user":null} or id, name, email, and provider.

Ingress admission and HTML data ownership are separate. Protecting /admin does not make its collections owner-only. See the HTML app reference.

handlers

Handlers receive verified identity as environment variables:

#!/usr/bin/env bash
printf 'Content-Type: application/json\r\n\r\n'
jq -n --arg id "$KEDGE_AUTH_SUBJECT" --arg email "$KEDGE_AUTH_EMAIL" \
  '{id: $id, email: $email}'

The complete set is KEDGE_AUTH_SUBJECT, KEDGE_AUTH_EMAIL, KEDGE_AUTH_PROVIDER, and KEDGE_AUTH_ASSERTION.

A runtime that serves requests concurrently keeps per-request identity off the shared process environment. The JavaScript and TypeScript handler(req) shapes therefore read the same identity from req.headers:

module.exports = (req) => ({id: req.headers["x-kedge-auth-subject"] || null});

services

Container services receive:

X-Kedge-Auth-Subject: <stable identity>
X-Kedge-Auth-Email: person@example.com
X-Kedge-Auth-Provider: passkey | email | kedge | google | github
X-Kedge-Auth-Assertion: <short-lived Ed25519 JWS>

Kedge strips client-supplied X-Kedge-* headers and auth cookies before adding these headers. Verify the assertion when identity crosses another internal hop. The verification key is at GET /_kedge/auth/jwks.json.

app-user operations

The owner-authenticated REST API lists, enables, and disables app users, reports configured providers, and revokes app sessions. Disabling a user revokes that user's sessions immediately.

Authentication is ingress authorization, not a role system. Enforce operation-level authorization in HTML binding audiences or application code.

Compose configuration

Compose uses the same shorthands and flat structure:

x-kedge:
  auth: github

A compose.yaml containing only x-kedge does not create a container service. Use the structured form to combine identities or select paths:

x-kedge:
  auth:
    paths: ["/admin/**"]
    except: ["/admin/health"]
    owner: true
    emails: [friend@example.com]
    google: [person@gmail.com, example.com]
    github: [octocat, my-workspace]

paths defaults to ["/**"]; except wins. Top-level auth is the default for every Compose service. A service-level x-kedge.auth replaces it.

Optional identity resolution is also available without an ingress gate:

x-kedge:
  identity:
    github: true

Markdown front matter uses that same structure. With no paths, inline auth applies only to /members:

---
auth:
  github: [my-org]
---

See the Compose reference.