bella.network Blog

Protecting Self-Hosted Apps with PassBeyond: A SAML SSO Reverse Proxy

Learn how PassBeyond adds SAML-based Single Sign-On (SSO) in front of existing self-hosted apps as a stateless reverse proxy - without changing application code, using JWT sessions and HTTP headers for identity.
Securing access to web applications
Securing access to web applications (Photo by Miguel Á. Padriñán on Pexels)

Self-hosted web applications are everywhere - from homelabs to critical internal tools - but many of them were never designed for modern SSO. With the proliferation of self-hosted applications, managing user authentication can become a complex task. Enter PassBeyond, a lightweight SAML Service Provider designed to act as a reverse proxy, enabling Single Sign-On (SSO) for your web applications without the need for extensive code changes or additional portals.

My problem

In my home lab and at work I run a lot of web applications: dashboards, admin tools, monitoring, password managers, wiki, storage, you name it. Most of them are “classic” self-hosted apps that were never designed to speak modern SAML or OIDC - if they support authentication at all, it’s usually local users or some basic LDAP integration if you’re lucky.

Managing users and passwords separately in every single app is a nightmare - especially when you want to enforce strong authentication, MFA, or just have a clean onboarding/offboarding process.

What I actually wanted was:

  • One central Identity Provider (IdP)
  • Single Sign-On (SSO) for all those apps with automatic login for users
  • No passwords stored inside every single tool
  • Centralized user and group management
  • A clean way to enforce authentication before an app is even reachable
  • Only allowing access to authorized users, limiting exposure and attack surface

And I wanted that without rewriting or replacing all existing applications. (Including Closed Source ones where I can’t even change the code.)

The natural pattern for that is: put a reverse proxy in front of everything, ensure users hit the proxy first, and only forward traffic if the user is authenticated. The backend shouldn’t need to know anything about SAML - just trust HTTP headers. In the worst case, the user needs to log in to the application again, but at least the application is secured against unauthorized access.

Sounds simple. In practice… not so much.

Requirements for a solution

So I wrote down what I actually needed:

  • Reverse-proxy first The component should behave like a transparent reverse proxy. Users hit myapp.example.com, and this service decides:

    • authenticated -> forward to app
    • not authenticated -> send to IdP
  • No vendor lock-in It should work with standard SAML 2.0 IdPs: Authentik, AD FS, Azure AD, Okta, Ping, Cisco Duo, etc.

  • Headers instead of SDKs Apps should receive identity via HTTP headers (username, email, groups, etc.). No need to touch application code, no SDKs, no special libraries. Just standard HTTP with optional integration based on provided headers.

  • Simple deployment

    • Install via APT or container
    • One config file per service
    • No external database or dependencies required
    • Automatic metadata updating support
  • One instance per domain By design I wanted a clear mapping: one PassBeyond instance <-> one protected FQDN That keeps configuration and debugging predictable on both the IdP and proxy side.

Security requirements

Security was non-negotiable:

  • Sessions must be signed and tamper-proof
  • Session state should be stateless (no central DB to scale or replicate)
  • Only HTTPS, secure cookies, proper SameSite handling
  • Clear behavior when tokens expire

I decided to use JWTs stored in a cookie for the session:

  • User logs in at the IdP -> PassBeyond builds a signed JWT with the claims it needs.
  • The JWT lives in a cookie and is checked on every request.
  • If the token is expired, the user must re-authenticate at the IdP.

This design has one important trade-off (and I call it out clearly in the docs):

Attribute / group changes and user deactivation in the IdP only become effective after the current token expires and the user logs in again.

If you need instant revocation, a pure JWT model is not ideal. For my use case (internal apps, well-controlled IdP, reasonable session lifetimes), the simplicity and robustness were worth it. If you need to invalidate sessions immediately, you can rotate the JWT signing key (or change the signing configuration). This will invalidate all existing tokens at once.

The benefit of this design is that PassBeyond remains stateless and easy to scale: no database, no session store, no replication. Just sign tokens and verify them. (Which also allows PassBeyond to run in a highly available setup with multiple instances behind a load balancer.)

User experience

The user perspective should be boring - in a good way:

  • They open grafana.example.com.
  • If they’re not already authenticated, they will be redirected to their usual IdP login. (Most likely automatically logged in via existing SSO session there, which means that in practice they often don’t even see a login prompt.)
  • After login, they land in Grafana, already recognized with the right user and groups.
  • All error pages should be friendly, explain what went wrong, and provide a support link.

As an example, such an error page looks like this:

PassBeyond Loop Detected Error

No extra portals, no special URLs, no “log in via this separate tool first, then go here”.


Possible solutions I looked at

Before building anything, I tried to solve this with existing software.

Authentik

Authentik is a very powerful identity provider. It supports SAML, OIDC, reverse proxy mode, user management, workflows… pretty much the whole identity lifecycle.

I like Authentik a lot - but it comes with a price:

  • It wants to be the central IdP and user directory
  • It has its own UI, user and group management, policies, flows, etc.
  • Reverse-proxy functionality is just one of many features

For setups where you don’t want an additional IdP or portal - e.g. you already use AD FS, Azure AD, Okta, Ping, Duo - this is often overkill. I just wanted:

“Tiny service sitting behind nginx, talking SAML to the IdP and headers to my apps.”

And for that specific niche, Authentik was too much, not too little.

Azure AD Application Proxy

Azure AD Application Proxy is Microsoft’s offering to publish internal apps to the internet with Azure AD as IdP.

Pros:

  • Works well if you’re already all-in on Azure
  • Deep integration with Azure AD, Conditional Access, etc.

But the drawbacks for my use case:

  • It’s cloud-only; you depend on Azure infrastructure
  • It’s tied to Azure AD as IdP - not ideal if you want to stay independent or use a different SAML IdP
  • Licensing and per-user models can get annoying for labs and small environments

For a self-hosted, fully under-your-control setup, I wanted something that lives next to nginx on my own servers, not in someone else’s cloud.

So I built PassBeyond

At some point it was obvious: I couldn’t find a tool that was:

  • only a SAML Service Provider + reverse proxy,
  • had no own web UI besides error pages,
  • was easy to install on Debian/Ubuntu via APT,
  • and kept the mental model clean: “protect this one domain with SAML”.

So I built PassBeyond.


Introducing PassBeyond

PassBeyond is a simple SAML Service Provider written in Go. It does exactly three things:

  1. Speaks SAML 2.0 with your IdP
  2. Manages an authenticated session via a JWT cookie
  3. Acts as a reverse proxy to your actual application and injects identity as HTTP headers

There is no portal, no account management, no fancy front end. Apart from error pages, it’s invisible.

How PassBeyond works

The flow, simplified:

  1. User visits https://myapp.example.com

  2. Nginx (or Apache, HAProxy, etc.) forwards the request to PassBeyond.

  3. PassBeyond checks the session cookie:

    • If valid -> pass the request through to the backend and add headers
    • If missing / expired -> start SAML authentication
  4. User is redirected to the IdP, logs in as usual

  5. IdP sends a SAML Response back to PassBeyond

  6. PassBeyond validates the response, extracts attributes, issues a signed JWT and stores it as a cookie

  7. The original request is replayed, now with a valid session; headers like X-Passbeyond-Email, X-Passbeyond-Groups, etc. are added and forwarded to your application

The backend app does not know about SAML. It just sees headers and can apply its own logic if needed.

Why a reverse-proxy-only design?

This was a very deliberate decision:

  • You can reuse your existing HTTP stack (nginx / Apache), certificates, QUIC/HTTP/2 setup, HSTS, etc.
  • You’re not forced into any opinionated routing or TLS termination model.
  • If you ever want to replace PassBeyond, your reverse proxy config stays mostly the same.

PassBeyond is one component in your architecture, not the center of the universe.

Advantages in practice

Some nice side effects of this design:

  • Transparent to apps

    • Works with almost any HTTP(S) application
    • Especially useful for old tools running locally with only “admin / admin” capabilities
  • Easy for self-written apps

    • If you have custom apps, adding SSO is as simple as reading headers
    • No need to integrate SAML SDKs or libraries
    • Just read the X-Passbeyond-Email, X-Passbeyond-Groups, etc. headers and let your app do the rest
  • Stateless and easy to scale

    • No database - sessions are JWTs
    • You can run multiple PassBeyond instances behind a load balancer; as long as they share config and signing keys, sessions work across instances.
    • Easy to back up and restore - just config files and signing keys
  • One instance per domain

    • Clear operational model: one systemd unit per service, e.g. passbeyond@grafana.service, passbeyond@confluence.service, etc.
  • OS-level integration

    • Install via APT from my repository (repo.bella.network)
    • Config files under /etc/passbeyond/
    • systemd for lifecycle management

Configuration and typical scenarios

Basic setup

For Debian/Ubuntu, the installation looks like this (summarised):

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
echo "Types: deb
URIs: https://repo.bella.network/deb
Suites: stable
Components: main
Architectures: amd64 arm64
Signed-By: /usr/share/keyrings/bella-archive-keyring.gpg
" | sudo tee /etc/apt/sources.list.d/repo.bella.network.sources

curl -fsSL https://repo.bella.network/_static/bella-archive-keyring.gpg \
  | sudo tee /usr/share/keyrings/bella-archive-keyring.gpg >/dev/null

sudo apt update && sudo apt install passbeyond

Then you create a YAML configuration file, for example:

/etc/passbeyond/grafana.yaml:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
domain: "grafana.example.com"

idpMetadataURL: "https://sso.example.com/federationmetadata/2007-06/federationmetadata.xml"

proxy:
  listenAddress: ":8123"
  targetURL: "http://127.0.0.1:3000"
  targetDisableSSLVerify: false
  stripVersionHeaders: true
  useBasicAuth: false

sessionTimeout: 1440        # 24 hours
samlRequestSigning: false

#supportURL: "https://helpdesk.example.com"
#footer: "Example Corp - IT Support"

Enable it via systemd:

1
systemctl enable --now passbeyond@grafana.service

Now PassBeyond listens on 127.0.0.1:8123 and expects to be fronted by nginx:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
upstream passbeyond_grafana {
  server 127.0.0.1:8123;
  keepalive 32;
}

server {
  listen 443 ssl;
  server_name grafana.example.com;

  ssl_certificate /etc/letsencrypt/live/grafana.example.com/fullchain.pem;
  ssl_certificate_key /etc/letsencrypt/live/grafana.example.com/privkey.pem;

  location / {
    proxy_pass http://passbeyond_grafana;
    proxy_set_header Host $host;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header X-Real-IP $remote_addr;
    proxy_http_version 1.1;
    proxy_set_header Upgrade $http_upgrade;
    proxy_set_header Connection "upgrade";
    proxy_buffering off;
  }
}

From the user’s perspective, this is just “Grafana with SSO”.

Extended configuration options

Some features that become useful in more complex environments:

  • passthroughPaths Allow unauthenticated access to specific paths - e.g. /health, /metrics, static assets or public status pages.

  • passthroughTokens For non-interactive clients (monitoring, backup, API consumers) you can configure tokens that bypass SAML when provided via: X-Passbeyond-Authorization: Bearer <token>

  • supportURL and footer These appear on PassBeyond’s error pages and help users understand where to get help when SSO is misconfigured or fails. This is especially useful in larger organizations with dedicated support teams.

  • Environment variables instead of YAML For container deployments you can configure almost everything via PASSBEYOND_* environment variables. Only the dynamic configuration file needs to be on disk which holds signing keys, cache of IdP metadata, etc.

Supported Identity Providers

PassBeyond has been successfully tested with:

  • Authentik
  • Cisco Duo
  • Microsoft AD FS
  • Microsoft Azure AD
  • Okta
  • PingOne

Plus any IdP that speaks SAML 2.0 and can provide the basic user claims. The built-in claim mapping understands common schema URIs and aliases for:

  • distinguishedName, UPN, sAMAccountName, mail, givenName, surname, company, department, telephoneNumber
  • Groups (multiple attributes, including memberOf etc.)

The first matching attribute is mapped to a header, so you can work with the format your IdP already uses.


Real-world usage: what I’ve protected with PassBeyond

In my own environments, PassBeyond sits in front of a wide range of tools, for example:

  • Monitoring / logging

    • Grafana
    • Graylog
    • CheckMK
    • Frigate
  • Collaboration

    • Confluence (web)
    • Jira (web)
  • Infrastructure / admin

    • Proxmox
    • UniFi Controller
    • AdGuard Home
    • Guacamole
    • LibreSpeed
  • Self-hosted services

    • Nextcloud (web UI)
    • Vaultwarden / Bitwarden (web UI)
    • Firefly III
    • SonarQube
    • Syncthing (web UI)
    • wger

The pattern is always the same:

  1. Put the service behind PassBeyond
  2. Let PassBeyond authenticate via SAML
  3. Pass identity through as headers
  4. Optionally, map groups to application roles or use them only for auditing / logging

Mobile apps and desktop sync clients are a different story: many of them don’t understand SAML or don’t follow redirects nicely. In addition, many native clients do not handle SAML redirects or browser-based cookie flows at all. They often expect direct HTTP APIs without interactive redirects, so they will neither follow the SSO flow nor send back the cookies required for PassBeyond sessions. For those cases you either:

  • Use a VPN to get into the internal network, skipping PassBeyond entirely
  • Keep them on a separate domain without SAML
  • Use passthrough tokens or other authentication methods the app actually supports
  • Exclude API endpoints from PassBeyond using passthroughPaths (if security allows it)

This is not a limitation of PassBeyond in particular - it’s an ecosystem gap. SAML in browsers is well understood; SAML in native clients often isn’t.


Best practices and trade-offs

Best practices

Some recommendations if you plan to use PassBeyond:

  • Keep TLS termination strict Only expose PassBeyond over HTTPS. Use Secure, HttpOnly cookies and restrictive SameSite settings.

  • Reasonable session lifetimes Don’t make JWT sessions effectively infinite. Shorter timeouts reduce the window in which stale group membership or disabled accounts remain active.

  • Monitor logs Treat PassBeyond like any other security component: ship logs to your SIEM, alert on repeated failures, misconfigurations, and suspicious access.

  • One instance per FQDN Resist the urge to “multiplex” multiple apps behind one instance. Keeping the one-domain-per-instance model simplifies debugging, rollout, permissioning, and rollback.

  • Version-control your config Store /etc/passbeyond/*.yaml in Git (minus secrets), use CI/CD to roll out changes, and test new configs in a staging environment first.

Honest trade-offs

PassBeyond isn’t magic and doesn’t solve everything.

  • No central session revocation Because sessions are JWT-based and stateless, disabling a user or changing groups in the IdP only takes effect after token expiry. If you need global “log out everyone now”, you’d need additional mechanisms (short lifetimes, key rotation, extra logic in front).

  • SAML only OIDC isn’t supported. If you want OpenID Connect, you’ll need a separate solution or an IdP that offers both and use OIDC directly in apps that support it.

  • One instance per domain This is a design choice I like - but if you want a single gigantic front proxy routing dozens of apps with separate auth policies, you might prefer a more feature-rich product.


Conclusion: when PassBeyond makes sense

PassBeyond grew out of a very specific need:

“I already have a SAML IdP. I want a straightforward way to protect my existing web apps with SSO using my own reverse proxy, without managing yet another portal or rewriting every service.”

If that sentence sounds familiar, PassBeyond is probably a good fit:

  • You run self-hosted apps (on-prem or in your own cloud)
  • You have (or plan to have) a SAML 2.0 IdP
  • You like nginx / Apache / HAProxy and want to keep control over TLS and routing
  • You prefer small, composable components over heavy all-in-one platforms
  • You want to avoid a centralized authentication portal (single point of failure)

If you’re interested, check out the project GitLab page which includes documentation, installation instructions, and examples:

https://gitlab.com/bella.network/passbeyond

From there, the quickest way to see whether PassBeyond works for you is simple:

  1. Pick one non-critical internal app
  2. Put PassBeyond and your reverse proxy in front of it
  3. Wire it to your IdP
  4. Watch it disappear behind SSO - exactly as designed.

Project status: PassBeyond is actively maintained and developed. New features, bug fixes, and improvements are regularly released based on user feedback and evolving best practices. Licensed under the MIT License, used in production by several organizations and home labs.

Call for feedback

I’d love to hear your thoughts on PassBeyond! The idea might not be that young, but the current implementation and setup is still evolving. Whether you have questions, successfully implemented it with a new IdP or new service, you have feature requests, or feedback on your experience, please reach out:

Your input helps me improve the project and better serve your needs.