Internal apps.
GAAdmin 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.
How a request is decided
- The proxy sends the request's host, method, path, cookie and visitor address to TillAuth with the internal app's proxy key.
- TillAuth reads the rules top to bottom. The first rule whose path and method match decides; a request no rule matches is refused.
- 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.
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.
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 ./proxyThe 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.
Rules
[
{ "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"] }
}
]| Field | Meaning |
|---|---|
paths | Up 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. |
methods | Optional. Leave it out for every method; GET also covers HEAD. |
action | allow (with require), public (no sign-in) or deny. |
require.emails | Addresses that may pass. With domains or groups, any one is enough. |
require.domains | Verified addresses at these domains. |
require.groups | Members of any of these groups, by name, ignoring case. |
require.roles | The person’s role in the identity app. Checked on top of emails, domains and groups. |
require.mfa | Signed in with a second factor; without one, the person is asked for it. |
require.fresh_minutes | Signed in within this many minutes (5 to 1440); older sessions sign in again. |
require.max_risk | low or medium. A session scored above it signs in again. See session risk. |
require.countries | Two-letter countries the request may come from. |
require.ips | Addresses 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.
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
proxy_set_header X-TillDev-Access-Key "tdak_your_key_here";
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
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
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
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
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
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.
The identity your app receives
| Header | Value |
|---|---|
X-TillDev-User-Id | The person’s user id in the identity app. |
X-TillDev-Email | Their verified email address. |
X-TillDev-Role | Their role in the identity app. |
X-TillDev-Mfa | true when this session used a second factor. |
X-TillDev-Assertion | All 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.
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.
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--noworretire-key. - Sign out: people visit
/.tilldev/access/signouton the host. That ends their session on the internal app, not in your product.signout-allsigns 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 theaccess.signed_inandaccess.signed_outwebhooks.
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.