A zero-trust network access (ZTNA) reverse proxy. It sits in front of internal HTTP apps (Grafana, ArgoCD, etc.), authenticates users against an OIDC provider, checks group membership, and forwards allowed requests upstream with identity headers (X-Beyond-Email, X-Beyond-Groups, ...). Inspired by presbrey/beyond.
Apps and their allowed groups are declared in a YAML config:
portal:
host: example.com
title: Applications
applications:
grafana:
upstream: http://grafana:3000
host: grafana.example.com
display_name: Grafana
description: Metrics and dashboards
launch_url: https://grafana.example.com/
allowed_groups: [engineering, ops]When portal is configured, its host serves a small authenticated application
directory. It lists only applications allowed by the user's current groups;
administrators see all applications except entries marked hide_from_portal.
display_name, description, and launch_url are optional. Launch URLs
default to https://<host>/ and, when set explicitly, must remain on the
application's configured host. Set hide_from_portal: true for supporting
hosts such as a browser API origin.
The config is read once at startup; policy changes require a restart. beyond is stateless — run as many replicas as you like, as long as they share the same BEYOND_SESSION_SECRET.
For machine protocols whose OAuth discovery begins unauthenticated, an app may delegate a narrow set of exact paths to an independently authenticating upstream while retaining Beyond JWT verification for bearer requests:
applications:
mcp:
upstream: http://agent-gateway:4004
host: mcp.example.com
preserve_host: true
allowed_groups: [engineering]
bearer_auth:
issuer: https://auth.example.com/application/o/mcp/
jwks_url: http://authentik/application/o/mcp/jwks/
audience: mcp
unauthenticated_passthrough_paths:
- /mcp
- /.well-known/oauth-protected-resource/mcpOnly requests with no Authorization header use this path. Bearer requests
still receive Beyond's JWT and group-policy checks.
For the delegated paths themselves, Beyond applies no authorization:
allowed_groups is not enforced, no identity is injected, and any client can
reach them. Beyond strips inbound X-Beyond-* identity headers and forwards
the request. The upstream is therefore the sole access-control boundary for
every delegated path and must authenticate it (for example, by returning a
401 with WWW-Authenticate) or intentionally serve it anonymously. Never
delegate a path that exposes data or actions before upstream authentication.
For an upstream that authenticates opaque bearer tokens Beyond cannot verify,
use exact upstream_auth_passthrough_paths instead:
applications:
opaque-mcp:
upstream: http://agent-gateway:4011
host: opaque-mcp.example.com
allowed_groups: [engineering]
upstream_auth_passthrough_paths:
- /mcp
- /.well-known/oauth-protected-resource/mcpThese paths accept either no Authorization header for protocol bootstrap or
a non-empty Bearer credential, which is forwarded unchanged. Beyond does not
apply allowed_groups or inject identity. Other schemes and all unlisted,
nearby, escaped, or non-canonical paths retain Beyond's normal authentication.
Use this only when the upstream independently fails closed on protected paths.
Split-origin browser applications can opt in to
cors_preflight_passthrough: true when their API uses bearer_auth. Beyond
delegates only credential-free OPTIONS requests containing both Origin and
Access-Control-Request-Method; the upstream remains responsible for its CORS
policy. Actual API requests still require Beyond's normal session or verified
bearer token.
Beyond's own bearer-path responses (401, 403, 429) carry no CORS headers, so a
split-origin UI can't read them: the browser reports a network error. Two
opt-in bearer_auth settings support browser clients:
bearer_auth:
issuer: https://auth.example.com/application/o/app/
jwks_url: http://authentik/application/o/app/jwks/
audience: app
cors_origins: [https://app.example.com]
signal_expiry: truecors_origins lists exact https origins allowed to read the responses Beyond
generates on the bearer path. Proxied responses keep only the upstream's CORS
headers. signal_expiry answers an expired token with
{"status":401,"message":"jwt expired"} (the wording clients of
jsonwebtoken backends, such as GrowthBook, refresh on) instead of the opaque
invalid token, and stops expired tokens from spending the failure
budget. Only a genuinely signed token can be reported as expired, because the
signature is verified first.
preserve_host: true sends the configured public application hostname in the
upstream HTTP Host header while still dialing upstream. This is useful when
the upstream derives OAuth metadata or absolute URLs from Host; Beyond still
sets authoritative X-Forwarded-Host and X-Forwarded-Proto headers as usual.
Beyond removes every client-supplied X-Beyond-* header before proxying a
request. For an authenticated request, it adds the verified identity to the
outbound request:
| Header | Value | How to use it |
|---|---|---|
X-Beyond-User |
The user's verified email address | Email-valued user identifier; it is currently identical to X-Beyond-Email. |
X-Beyond-Email |
The user's verified email address | Use as the stable user identifier for new integrations. |
X-Beyond-Name |
The user's display name | Use only for display; the header is omitted when no name is available. |
X-Beyond-Groups |
Verified group names joined with | |
Split on | and compare complete group names; the header is omitted when the user has no groups. |
Configured grafana_role_projection.header (commonly X-Beyond-Role) |
Admin, Editor, Viewer, or None |
Optional, app-specific Grafana role; present only when role projection is configured. |
Treat the headers as trusted only when the upstream cannot be reached except
through Beyond (for example, enforce this with a private network or network
policy). A directly reachable upstream lets callers forge the same headers.
Treat a missing identity header as unauthenticated and fail closed; Beyond
intentionally injects no identity on configured unauthenticated or
authentication-passthrough paths. Use X-Beyond-Email, not the display name,
for identity, and perform exact group comparisons rather than substring
matches.
Images are published to ghcr.io/bluesky-social/beyond.
docker run --rm \
-p 443:443 \
-v /path/to/beyond.yaml:/etc/beyond.yaml:ro \
-e BEYOND_CONFIG=/etc/beyond.yaml \
-e BEYOND_SESSION_SECRET=<32-byte hex key> \
-e BEYOND_OIDC_ISSUER=https://auth.example.com/application/o/beyond/ \
-e BEYOND_OIDC_CLIENT_ID=... \
-e BEYOND_OIDC_CLIENT_SECRET=... \
-e BEYOND_DB_URL=postgresql://... \
ghcr.io/bluesky-social/beyond:latestbeyond serves plain HTTP unless you hand it a cert with BEYOND_TLS_CERT/BEYOND_TLS_KEY — terminate TLS at your load balancer or mount a cert. Run beyond serve --help for the full list of flags. The metrics/pprof server listens on :6060 and should never be exposed publicly.
You need Go, docker compose, OpenSSL, and just.
just install-tools # one-time: golangci-lint + gotestsum
just up # dev stack: Authentik, Postgres, echo server
just run beyond serve
just # lint + test
just downThe development portal is at https://localhost:8443. just up generates an
ignored, self-signed certificate for localhost and echo.localhost; accept
or locally trust that certificate when using a browser. Sign in with
test@beyond.local / test.
Unit tests need no infrastructure; e2e/DB tests want the dev stack up and skip themselves otherwise. The dev Authentik login is test@beyond.local / test.
Releases are cut by release-please through the org release pipeline (shared workflow).
Commit messages need a type: prefix, but any word works. PRs are
squash-merged, so the PR title becomes the commit, and a required
pr-title / pr-title check enforces the prefix. release-please drops commits without
one. feat: releases a minor version, a breaking change (feat!:, or a
BREAKING CHANGE: footer) a major one (a minor while the version is 0.x),
and anything else, including subsystem prefixes like ask:, a patch.
Release notes are built from merged PR titles, so write the title as the
changelog line you want. Direct pushes appear only in the release's
compare link.
How a release happens. Every push to main updates a standing
chore(main): release X.Y.Z PR maintained by bsky-public-release-bot. Merging it
tags vX.Y.Z and builds ghcr.io/bluesky-social/beyond:vX.Y.Z and, after re-checking that the released tree passed test, build and lint, dispatches the deploy into bluesky-social/deploy.
- If every commit since the last tag came from a merged Renovate or Dependabot PR, the Release PR merges itself on the hourly run, weekdays 09:00-16:00 PT. Otherwise a human merges it.
- Any human-authored commit in that range, even one that doesn't release on its own, means a human merges the next Release PR.
- Label the Release PR
release:manualto hold a release.
Dependencies come from Renovate with the org preset
(renovate.json). Don't push to a Renovate PR or click "Update branch" on
one: that adds a human commit, so the next release needs a human. If a bump
needs code changes, make them in a separate PR.
CI. main requires test, build, lint, pr-title / pr-title, govulncheck,
gitleaks and actionlint to pass, and PRs must be up to date with main
before they merge.
Dual MIT/Apache-2.0, see LICENSE-DUAL.