TILLAUTH · OPERATE

Internal apps.

GA

Admin panels, dashboards, staging sites and vendor tools you host yourself, behind the same sign-in your product uses, without changing them. The proxy you already run asks TillAuth about every request; TillAuth answers per path, and the app receives the person's identity as headers it can verify.

01Model

How a request is decided

  1. The proxy sends the request's host, method, path, cookie and visitor address to TillAuth with the internal app's proxy key.
  2. TillAuth reads the rules top to bottom. The first rule whose path and method match decides; a request no rule matches is refused.
  3. Allowed: the proxy passes the request on with the identity headers. Sign-in needed: a page view goes to your app's sign-in and comes back to the same path; anything else gets a 401. Refused: a 403 page that says why, without naming the rule.

The sign-in is your app's hosted sign-in, with its passkeys, second factors and company sign-in. It leaves a session on that host only, for 1 to 24 hours (8 by default). Revoking the person's sessions, removing them from the app or a request that crosses the app's session-risk policy ends access too.

02Start

Create an internal app

In the dashboard, open the identity app and choose Internal apps, or use the CLI. Give it the exact host names people type (up to 20, no wildcards) and who may sign in. You get the proxy key once.

bash
tilldev auth access my-app create --name "Admin panel" --host admin.example.com --allow-domain example.com
tilldev auth access my-app setup <access-id> --proxy nginx --upstream http://127.0.0.1:3000 --out ./proxy

The starting rule lets the named people, domains or groups (--allow-group) in everywhere, with a second factor. Owners and admins of the workspace manage internal apps; changing one asks for a recent sign-in, like other sensitive settings.

03Policy

Rules

rules.json
[
  { "name": "Health check", "paths": ["/healthz"], "action": "public" },
  { "name": "Old exports", "paths": ["/exports/legacy/*"], "action": "deny" },
  {
    "name": "Billing",
    "paths": ["/billing/*"],
    "methods": ["POST", "PUT", "PATCH", "DELETE"],
    "action": "allow",
    "require": { "roles": ["admin"], "mfa": true, "fresh_minutes": 15 }
  },
  {
    "name": "Ops",
    "paths": ["/ops/*"],
    "action": "allow",
    "require": { "groups": ["On call", "Platform Eng"], "mfa": true }
  },
  {
    "name": "Team",
    "paths": ["/*"],
    "action": "allow",
    "require": { "domains": ["example.com"], "mfa": true, "max_risk": "medium", "countries": ["KE", "RW"] }
  }
]
FieldMeaning
pathsUp to 20 paths. A path ending in * matches everything under it; others match exactly. Paths are normalised first, and one that could mean two things is refused.
methodsOptional. Leave it out for every method; GET also covers HEAD.
actionallow (with require), public (no sign-in) or deny.
require.emailsAddresses that may pass. With domains or groups, any one is enough.
require.domainsVerified addresses at these domains.
require.groupsMembers of any of these groups, by name, ignoring case.
require.rolesThe person’s role in the identity app. Checked on top of emails, domains and groups.
require.mfaSigned in with a second factor; without one, the person is asked for it.
require.fresh_minutesSigned in within this many minutes (5 to 1440); older sessions sign in again.
require.max_risklow or medium. A session scored above it signs in again. See session risk.
require.countriesTwo-letter countries the request may come from.
require.ipsAddresses or ranges (IPv4 or IPv6) the request may come from.

Up to 50 rules. Try a draft before saving it: the simulator in the dashboard, or tilldev auth access my-app simulate <access-id> /billing --user ana@example.com --no-mfa, answers what the rules would do and which rule decided. Saved changes reach every proxy within 15 seconds.

04Connect

Your proxy

The dashboard and tilldev auth access … setup fill these in with your hosts and upstream. None of them holds the key: each reads TILLDEV_ACCESS_KEY or carries a placeholder to replace. Shown here for admin.example.com.

nginx

tilldev-access-key.conf
proxy_set_header X-TillDev-Access-Key "tdak_your_key_here";
tilldev-access.conf
server {
  listen 443 ssl;
  ssl_certificate     /etc/ssl/certs/your-site.pem;
  ssl_certificate_key /etc/ssl/private/your-site.key;
  server_name admin.example.com;

  location = /.tilldev-check {
    internal;
    proxy_pass https://auth.tilldev.dev/v1/access/check;
    proxy_pass_request_body off;
    proxy_set_header Content-Length "";
    proxy_set_header Host auth.tilldev.dev;
    proxy_ssl_server_name on;
    include tilldev-access-key.conf;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-Original-URI $request_uri;
    proxy_set_header X-Original-Method $request_method;
    proxy_set_header X-TillDev-Client-Ip $remote_addr;
    proxy_set_header X-TillDev-Access-Mode status;
  }

  location /.tilldev/access/ {
    proxy_pass https://auth.tilldev.dev/v1/access/flow/;
    proxy_set_header Host auth.tilldev.dev;
    proxy_ssl_server_name on;
    include tilldev-access-key.conf;
    proxy_set_header X-Forwarded-Host $host;
    proxy_set_header X-TillDev-Client-Ip $remote_addr;
  }

  location @tilldev_signin {
    return 302 /.tilldev/access/start?rd=$request_uri;
  }

  location / {
    auth_request /.tilldev-check;
    auth_request_set $tilldev_0 $upstream_http_x_tilldev_user_id;
    auth_request_set $tilldev_1 $upstream_http_x_tilldev_email;
    auth_request_set $tilldev_2 $upstream_http_x_tilldev_role;
    auth_request_set $tilldev_3 $upstream_http_x_tilldev_mfa;
    auth_request_set $tilldev_4 $upstream_http_x_tilldev_assertion;
    error_page 401 = @tilldev_signin;
    proxy_set_header X-TillDev-User-Id $tilldev_0;
    proxy_set_header X-TillDev-Email $tilldev_1;
    proxy_set_header X-TillDev-Role $tilldev_2;
    proxy_set_header X-TillDev-Mfa $tilldev_3;
    proxy_set_header X-TillDev-Assertion $tilldev_4;
    proxy_set_header Host $host;
    proxy_pass http://127.0.0.1:3000;
  }
}
  • Needs the auth_request module, which most nginx packages include (nginx -V lists --with-http_auth_request_module).
  • Keep tilldev-access-key.conf readable by nginx only (chmod 600) next to nginx.conf, or give include its full path.
  • Behind a load balancer, set real_ip_header and set_real_ip_from so $remote_addr is the visitor, not the balancer.

Caddy

Caddyfile
admin.example.com {
	handle_path /.tilldev/access/* {
		rewrite * /v1/access/flow{path}
		reverse_proxy https://auth.tilldev.dev {
			header_up Host {upstream_hostport}
			header_up X-TillDev-Access-Key {env.TILLDEV_ACCESS_KEY}
			header_up X-Forwarded-Host {host}
			header_up X-TillDev-Client-Ip {client_ip}
		}
	}

	handle {
		route {
			request_header -X-TillDev-User-Id
			request_header -X-TillDev-Email
			request_header -X-TillDev-Role
			request_header -X-TillDev-Mfa
			request_header -X-TillDev-Assertion
			forward_auth https://auth.tilldev.dev {
				uri /v1/access/check
				header_up Host {upstream_hostport}
				header_up X-TillDev-Access-Key {env.TILLDEV_ACCESS_KEY}
				header_up X-Forwarded-Host {host}
				header_up X-TillDev-Client-Ip {client_ip}
				copy_headers X-TillDev-User-Id X-TillDev-Email X-TillDev-Role X-TillDev-Mfa X-TillDev-Assertion
			}
			reverse_proxy 127.0.0.1:3000
		}
	}
}
  • Start Caddy with TILLDEV_ACCESS_KEY set to the key, for example in the service's environment file.
  • Behind a load balancer, list it in trusted_proxies so {client_ip} is the visitor.

Traefik

tilldev-access.yml
http:
  routers:
    tilldev-app:
      rule: "Host(`admin.example.com`)"
      entryPoints: [websecure]
      middlewares: [tilldev-key, tilldev-check, tilldev-unkey]
      service: tilldev-upstream
      tls: {}
    tilldev-flow:
      rule: "(Host(`admin.example.com`)) && PathPrefix(`/.tilldev/access/`)"
      entryPoints: [websecure]
      middlewares: [tilldev-key, tilldev-flow-path]
      service: tilldev-auth
      tls: {}

  middlewares:
    tilldev-key:
      headers:
        customRequestHeaders:
          X-TillDev-Access-Key: "tdak_your_key_here"
    tilldev-check:
      forwardAuth:
        address: "https://auth.tilldev.dev/v1/access/check"
        authResponseHeaders:
          - X-TillDev-User-Id
          - X-TillDev-Email
          - X-TillDev-Role
          - X-TillDev-Mfa
          - X-TillDev-Assertion
    tilldev-unkey:
      headers:
        customRequestHeaders:
          X-TillDev-Access-Key: ""
    tilldev-flow-path:
      replacePathRegex:
        regex: "^/\\.tilldev/access/(.*)"
        replacement: "/v1/access/flow/$1"

  services:
    tilldev-upstream:
      loadBalancer:
        servers:
          - url: "http://127.0.0.1:3000"
    tilldev-auth:
      loadBalancer:
        passHostHeader: false
        servers:
          - url: "https://auth.tilldev.dev"
  • A file-provider dynamic configuration. The same middlewares exist as Kubernetes Middleware resources.
  • Traefik’s forwardAuth cannot send the visitor’s address, so rules that name networks or countries refuse every request here. Limit networks with Traefik’s ipAllowList middleware instead.
  • authResponseHeaders replaces any copy of those headers the visitor sent.

Envoy

envoy.yaml
static_resources:
  listeners:
  - name: tilldev_access
    address:
      socket_address: { address: 0.0.0.0, port_value: 443 }
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: tilldev_access
          route_config:
            virtual_hosts:
            - name: protected
              domains: ["admin.example.com"]
              routes:
              - match: { prefix: "/.tilldev/access/" }
                route:
                  cluster: tilldev_auth
                  prefix_rewrite: "/v1/access/flow/"
                  host_rewrite_literal: auth.tilldev.dev
                request_headers_to_add:
                - header: { key: x-tilldev-access-key, value: "tdak_your_key_here" }
                  append_action: OVERWRITE_IF_EXISTS_OR_ADD
                - header: { key: x-forwarded-host, value: "%REQ(:authority)%" }
                  append_action: OVERWRITE_IF_EXISTS_OR_ADD
                - header: { key: x-tilldev-client-ip, value: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%" }
                  append_action: OVERWRITE_IF_EXISTS_OR_ADD
                typed_per_filter_config:
                  envoy.filters.http.ext_authz:
                    "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthzPerRoute
                    disabled: true
              - match: { prefix: "/" }
                route: { cluster: upstream }
          http_filters:
          - name: envoy.filters.http.ext_authz
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.ext_authz.v3.ExtAuthz
              transport_api_version: V3
              http_service:
                server_uri: { uri: "http://127.0.0.1:15990", cluster: tilldev_auth_hop, timeout: 3s }
                path_prefix: /v1/access/check
                authorization_request:
                  headers_to_add:
                  - { key: x-tilldev-access-key, value: "tdak_your_key_here" }
                  - { key: x-forwarded-host, value: "%REQ(:authority)%" }
                  - { key: x-forwarded-method, value: "%REQ(:method)%" }
                  - { key: x-tilldev-client-ip, value: "%DOWNSTREAM_REMOTE_ADDRESS_WITHOUT_PORT%" }
                authorization_response:
                  allowed_upstream_headers:
                    patterns: [{ exact: x-tilldev-user-id }, { exact: x-tilldev-email }, { exact: x-tilldev-role }, { exact: x-tilldev-mfa }, { exact: x-tilldev-assertion }]
              allowed_headers:
                patterns: [{ exact: cookie }, { exact: user-agent }, { exact: accept }, { exact: sec-fetch-mode }]
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
  - name: tilldev_auth_hop
    address:
      socket_address: { address: 127.0.0.1, port_value: 15990 }
    filter_chains:
    - filters:
      - name: envoy.filters.network.http_connection_manager
        typed_config:
          "@type": type.googleapis.com/envoy.extensions.filters.network.http_connection_manager.v3.HttpConnectionManager
          stat_prefix: tilldev_auth_hop
          route_config:
            virtual_hosts:
            - name: tilldev_auth
              domains: ["*"]
              routes:
              - match: { prefix: "/v1/access/check" }
                route: { cluster: tilldev_auth, host_rewrite_literal: auth.tilldev.dev }
          http_filters:
          - name: envoy.filters.http.router
            typed_config:
              "@type": type.googleapis.com/envoy.extensions.filters.http.router.v3.Router
  clusters:
  - name: tilldev_auth
    type: LOGICAL_DNS
    dns_lookup_family: V4_ONLY
    load_assignment:
      cluster_name: tilldev_auth
      endpoints:
      - lb_endpoints:
        - endpoint: { address: { socket_address: { address: auth.tilldev.dev, port_value: 443 } } }
    transport_socket:
      name: envoy.transport_sockets.tls
      typed_config:
        "@type": type.googleapis.com/envoy.extensions.transport_sockets.tls.v3.UpstreamTlsContext
        sni: auth.tilldev.dev
  - name: tilldev_auth_hop
    type: STATIC
    load_assignment:
      cluster_name: tilldev_auth_hop
      endpoints:
      - lb_endpoints:
        - endpoint: { address: { socket_address: { address: 127.0.0.1, port_value: 15990 } } }
  - name: upstream
    type: LOGICAL_DNS
    dns_lookup_family: V4_ONLY
    load_assignment:
      cluster_name: upstream
      endpoints:
      - lb_endpoints:
        - endpoint: { address: { socket_address: { address: 127.0.0.1, port_value: 3000 } } }
  • Add your listener’s TLS settings. The check runs as ext_authz over HTTP, with the request path appended to /v1/access/check.
  • ext_authz keeps your site’s host name on the check, so the check goes through a loopback listener on port 15990 that names TillAuth’s host. Pick another port if that one is taken.
  • Behind a load balancer, set use_remote_address and xff_num_trusted_hops so the downstream address is the visitor.

Cloudflare Worker

worker.js
const AUTH = "https://auth.tilldev.dev"
const IDENTITY = ["x-tilldev-user-id","x-tilldev-email","x-tilldev-role","x-tilldev-mfa","x-tilldev-assertion"]
const PASS = ['cookie', 'user-agent']

function toAuth(request, env, url) {
  const h = new Headers()
  for (const name of PASS) {
    const v = request.headers.get(name)
    if (v) h.set(name, v)
  }
  h.set('x-tilldev-access-key', env.TILLDEV_ACCESS_KEY)
  h.set('x-forwarded-host', url.hostname)
  const ip = request.headers.get('cf-connecting-ip')
  if (ip) h.set('x-tilldev-client-ip', ip)
  const country = request.cf && request.cf.country
  if (country) h.set('x-tilldev-client-country', country)
  return h
}

function wantsPage(request) {
  if (request.method !== 'GET' && request.method !== 'HEAD') return false
  const mode = request.headers.get('sec-fetch-mode')
  if (mode) return mode === 'navigate'
  const accept = request.headers.get('accept')
  return !accept || accept.includes('text/html') || accept.includes('*/*')
}

export default {
  async fetch(request, env) {
    const url = new URL(request.url)
    if (url.pathname.startsWith('/.tilldev/access/')) {
      const target = AUTH + '/v1/access/flow/' + url.pathname.slice('/.tilldev/access/'.length) + url.search
      return fetch(target, { method: request.method, headers: toAuth(request, env, url), redirect: 'manual' })
    }
    const h = toAuth(request, env, url)
    h.set('x-forwarded-uri', url.pathname + url.search)
    h.set('x-forwarded-method', request.method)
    h.set('x-tilldev-access-mode', 'status')
    const check = await fetch(AUTH + '/v1/access/check', { headers: h, redirect: 'manual' })
    if (check.status === 401 && wantsPage(request)) {
      const { signin } = await check.json()
      const to = new Headers({ location: new URL(signin, url).href, 'cache-control': 'no-store' })
      const cleared = check.headers.get('set-cookie')
      if (cleared) to.append('set-cookie', cleared)
      return new Response(null, { status: 302, headers: to })
    }
    if (check.status !== 200) return check
    const forward = new Headers(request.headers)
    for (const name of IDENTITY) {
      const v = check.headers.get(name)
      if (v) forward.set(name, v)
      else forward.delete(name)
    }
    const res = await fetch(new Request(request, { headers: forward }))
    const cleared = check.headers.get('set-cookie')
    if (!cleared) return res
    const out = new Response(res.body, res)
    out.headers.append('set-cookie', cleared)
    return out
  },
}
  • Route the Worker on admin.example.com and store the key as a secret named TILLDEV_ACCESS_KEY (wrangler secret put TILLDEV_ACCESS_KEY).
  • Your origin should only accept requests from Cloudflare, or the Worker can be gone around.

TillDev CLI

bash
export TILLDEV_ACCESS_KEY=tdak_your_key_here
tilldev auth access proxy --auth-url https://auth.tilldev.dev --upstream http://127.0.0.1:3000 --port 8080
  • Runs a small proxy with no extra installs. Put TLS in front of it (your load balancer, or a tunnel), since sign-in cookies need HTTPS.
  • Pass --trust-proxy when it sits behind a load balancer that sets X-Forwarded-For.
Close the side door
The proxy is the only thing between the internet and the app. Make the app listen only where the proxy can reach it, and verify the assertion in the app so a request that skipped the proxy can't claim to be someone.
05Verify

The identity your app receives

HeaderValue
X-TillDev-User-IdThe person’s user id in the identity app.
X-TillDev-EmailTheir verified email address.
X-TillDev-RoleTheir role in the identity app.
X-TillDev-Mfatrue when this session used a second factor.
X-TillDev-AssertionAll of the above in a signed token, for this internal app and this host only.

Every proxy above removes any copy of these headers the visitor sent. Even so, verify the assertion. It is an EdDSA token with type tilldev-access+jwt, lasts 5 minutes, and names its host and internal app. It also lists the person's groups: up to 100, sorted, with groups_overage set when there are more. The rules themselves always see every group. The internal app's page shows the issuer and audience to check.

ts
import { createAccessVerifier, AccessAssertionError } from '@tillstack/auth-node'

const access = createAccessVerifier({
  issuer: process.env.TILLDEV_ACCESS_ISSUER!,
  audience: process.env.TILLDEV_ACCESS_AUDIENCE!,
})

export async function whoIsThis(headers: Headers) {
  try {
    return await access.verifyRequest(headers) // { sub, email, role, groups, mfa, auth_time, host }
  } catch (e) {
    if (e instanceof AccessAssertionError) return null // e.code: no_assertion | invalid_assertion | wrong_host
    throw e
  }
}

Not on Node? Fetch the keys from <issuer>/.well-known/jwks.json and check the signature, iss, aud, typ, exp and host with any JWT library. See @tillstack/auth-node.

06Operate

Keys, sign-out and decisions

  • Rotate the key with tilldev auth access my-app rotate-key <access-id> --yes. The previous key keeps working for 24 hours, or stops at once with --now or retire-key.
  • Sign out: people visit /.tilldev/access/signout on the host. That ends their session on the internal app, not in your product. signout-all signs everyone out.
  • Pause an internal app to refuse every request while you change it.
  • Decisions: the dashboard and tilldev auth access my-app decisions <access-id> show who was allowed, asked to sign in or refused, on which path and by which rule. Repeats of the same decision are counted together over 30 seconds. Sign-ins and sign-outs are in the audit log and send the access.signed_in and access.signed_out webhooks.
07Limits

What to know

  • Network and country rules need the visitor's address and country from the proxy. Traefik can't send the address, so those rules refuse every request behind it; behind other proxies, the country needs a header from your edge (Cloudflare sends one).
  • Each request is compared with where its session signed in: another browser, network or country, or an address TillGate lists, raises the score, with the same weights and policy as a refresh. Travel speed is only judged at refresh, not per request.
  • Sign-in needs HTTPS on the host, because the session cookie is host-only and secure. The CLI proxy listens without TLS, so put it behind something that terminates TLS.
  • If TillAuth can't be reached, the proxies refuse the request rather than letting it through.
  • Host names match exactly. A wildcard or IP address is not accepted.