Repository: goauthentik/authentik
Pull request: root: support running authentik in subpath
<!--
👋 Hi there! Welcome.

Please check the Contributing guidelines: https://goauthentik.io/developer-docs/#how-can-i-contribute
-->

## Details

closes #4509

---

## Checklist

-   [ ] Local tests pass (`ak test authentik/`)
-   [ ] The code has been formatted (`make lint-fix`)

If an API change has been made

-   [ ] The API schema has been updated (`make gen-build`)

If changes to the frontend have been made

-   [ ] The code has been formatted (`make web`)

If applicable

-   [ ] The documentation has been updated
-   [ ] The documentation has been formatted (`make website`)


Problem statement:
Run Authentik in subfolder behind Traefik Proxy
**Describe your question/**
Due to various reasons I can't run authentik as subdomain (e.g. `auth.example.com`). I wan't to use authentik as a subfolder under my domain, e.g. `example.com/auth/`. Additionally I run authentik behind a traefik proxy like I do with many other services, also with subfolders ( example.com/service1/, example.com/service2/ etc.). 

The first call to `example.com/auth/` actually reaches authentik, because it redirects me to the first flow (`.../flows/-/default/authentication/?next=/`) BUT it cuts the subfolder so instead of `example.com/auth/flows/-/default/authentication/?next=/`  it cuts the subfolder and redirects to `example.com/flows/-/default/authentication/?next=/` resulting in a 404 error on traefik. 

In other docker containers there is something like `BASE_URL` or `ROOT_URL`, but I haven't found anything like that. 
I even searched and played around in `authentik/root/settings.py` and in `authentik/root/urls.py` but without success, but I'm a noob in python. 

**Relevant infos**
My traefik labels are:
```
- "traefik.enable=true"
- "traefik.http.routers.authentik-secure.entrypoints=websecure"
- "traefik.http.routers.authentik-secure.tls=true"
# route to subfolder
- "traefik.http.routers.authentik-secure.rule=Host(`example.com`) && PathPrefix(`/auth{regex:$$|/.*}`)"
- "traefik.http.routers.authentik-secure.middlewares=authentik-secure-stripprefix"
- "traefik.http.middlewares.authentik-secure-stripprefix.stripprefix.prefixes=/auth"
- "traefik.http.services.authentik-secure.loadbalancer.server.port=9000"
```
I tried the pathprefix with or without regex and with or without stripprefix. 


**Version and Deployment (please complete the following information):**
 - authentik version: latest
 - Deployment: docker-compose

Thanks for your help in advance!

Hints from issue discussion:
I have the same problem with Apache 2.4.

From `.env`:
```
COMPOSE_PORT_HTTP=8001
COMPOSE_PORT_HTTPS=9001
```

From `site.conf`:
```
<Location /auth>
  ProxyPass http://localhost:8001
</Location>
```

Result: requests to /auth get correctly proxied to authentik, but then the client is incorrectly sent to `/flows/-/default/authentication/?next=/`, instead of `/auth/flows/-/default/authentication/?next=/`.

Commit message: set cookie path

Signed-off-by: Jens Langhammer <jens@goauthentik.io>

Patch to review:
diff --git a/authentik/brands/api.py b/authentik/brands/api.py
index a856cee4858f..bb449a10e86b 100644
--- a/authentik/brands/api.py
+++ b/authentik/brands/api.py
@@ -84,8 +84,8 @@ class CurrentBrandSerializer(PassiveSerializer):
 
     matched_domain = CharField(source="domain")
     branding_title = CharField()
-    branding_logo = CharField()
-    branding_favicon = CharField()
+    branding_logo = CharField(source="branding_logo_url")
+    branding_favicon = CharField(source="branding_favicon_url")
     ui_footer_links = ListField(
         child=FooterLinkSerializer(),
         read_only=True,
diff --git a/authentik/brands/models.py b/authentik/brands/models.py
index dca4f724ad88..66ac341a3fa9 100644
--- a/authentik/brands/models.py
+++ b/authentik/brands/models.py
@@ -9,6 +9,7 @@
 
 from authentik.crypto.models import CertificateKeyPair
 from authentik.flows.models import Flow
+from authentik.lib.config import CONFIG
 from authentik.lib.models import SerializerModel
 
 LOGGER = get_logger()
@@ -60,6 +61,18 @@ class Brand(SerializerModel):
     )
     attributes = models.JSONField(default=dict, blank=True)
 
+    def branding_logo_url(self) -> str:
+        """Get branding_logo with the correct prefix"""
+        if self.branding_logo.startswith("/static"):
+            return CONFIG.get("web.path", "/")[:-1] + self.branding_logo
+        return self.branding_logo
+
+    def branding_favicon_url(self) -> str:
+        """Get branding_favicon with the correct prefix"""
+        if self.branding_favicon.startswith("/static"):
+            return CONFIG.get("web.path", "/")[:-1] + self.branding_favicon
+        return self.branding_favicon
+
     @property
     def serializer(self) -> Serializer:
         from authentik.brands.api import BrandSerializer
diff --git a/authentik/core/templates/base/header_js.html b/authentik/core/templates/base/header_js.html
index a67a0b3daea9..d8052ba4014f 100644
--- a/authentik/core/templates/base/header_js.html
+++ b/authentik/core/templates/base/header_js.html
@@ -9,6 +9,9 @@
         versionFamily: "{{ version_family }}",
         versionSubdomain: "{{ version_subdomain }}",
         build: "{{ build }}",
+        api: {
+            base: "{{ base_url }}",
+        },
     };
     window.addEventListener("DOMContentLoaded", () => {
         {% for message in messages %}
diff --git a/authentik/core/templates/base/skeleton.html b/authentik/core/templates/base/skeleton.html
index 377485948b90..4bfbd7535bff 100644
--- a/authentik/core/templates/base/skeleton.html
+++ b/authentik/core/templates/base/skeleton.html
@@ -8,8 +8,8 @@
         <meta charset="UTF-8">
         <meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1">
         <title>{% block title %}{% trans title|default:brand.branding_title %}{% endblock %}</title>
-        <link rel="icon" href="{{ brand.branding_favicon }}">
-        <link rel="shortcut icon" href="{{ brand.branding_favicon }}">
+        <link rel="icon" href="{{ brand.branding_favicon_url }}">
+        <link rel="shortcut icon" href="{{ brand.branding_favicon_url }}">
         {% block head_before %}
         {% endblock %}
         <link rel="stylesheet" type="text/css" href="{% static 'dist/authentik.css' %}">
diff --git a/authentik/core/templates/login/base_full.html b/authentik/core/templates/login/base_full.html
index c88692a63230..f2fb9378f605 100644
--- a/authentik/core/templates/login/base_full.html
+++ b/authentik/core/templates/login/base_full.html
@@ -4,7 +4,7 @@
 {% load i18n %}
 
 {% block head_before %}
-<link rel="prefetch" href="/static/dist/assets/images/flow_background.jpg" />
+<link rel="prefetch" href="{% static 'dist/assets/images/flow_background.jpg' %}" />
 <link rel="stylesheet" type="text/css" href="{% static 'dist/patternfly.min.css' %}">
 <link rel="stylesheet" type="text/css" href="{% static 'dist/theme-dark.css' %}" media="(prefers-color-scheme: dark)">
 {% include "base/header_js.html" %}
@@ -13,7 +13,7 @@
 {% block head %}
 <style>
 :root {
-    --ak-flow-background: url("/static/dist/assets/images/flow_background.jpg");
+    --ak-flow-background: url("{% static 'dist/assets/images/flow_background.jpg' %}");
     --pf-c-background-image--BackgroundImage: var(--ak-flow-background);
     --pf-c-background-image--BackgroundImage-2x: var(--ak-flow-background);
     --pf-c-background-image--BackgroundImage--sm: var(--ak-flow-background);
@@ -50,7 +50,7 @@
     <div class="ak-login-container">
         <main class="pf-c-login__main">
             <div class="pf-c-login__main-header pf-c-brand ak-brand">
-                <img src="{{ brand.branding_logo }}" alt="authentik Logo" />
+                <img src="{{ brand.branding_logo_url }}" alt="authentik Logo" />
             </div>
             <header class="pf-c-login__main-header">
                 <h1 class="pf-c-title pf-m-3xl">
diff --git a/authentik/core/views/interface.py b/authentik/core/views/interface.py
index 2a7dbda558cb..5eb01567d00d 100644
--- a/authentik/core/views/interface.py
+++ b/authentik/core/views/interface.py
@@ -12,6 +12,7 @@
 from authentik.api.v3.config import ConfigView
 from authentik.brands.api import CurrentBrandSerializer
 from authentik.flows.models import Flow
+from authentik.lib.config import CONFIG
 
 
 class InterfaceView(TemplateView):
@@ -24,6 +25,7 @@ def get_context_data(self, **kwargs: Any) -> dict[str, Any]:
         kwargs["version_subdomain"] = f"version-{LOCAL_VERSION.major}-{LOCAL_VERSION.minor}"
         kwargs["build"] = get_build_hash()
         kwargs["url_kwargs"] = self.kwargs
+        kwargs["base_url"] = self.request.build_absolute_uri(CONFIG.get("web.path", "/"))
         return super().get_context_data(**kwargs)
 
 
diff --git a/authentik/enterprise/providers/rac/templates/if/rac.html b/authentik/enterprise/providers/rac/templates/if/rac.html
index 1d1a033987e1..af9bcd491202 100644
--- a/authentik/enterprise/providers/rac/templates/if/rac.html
+++ b/authentik/enterprise/providers/rac/templates/if/rac.html
@@ -6,8 +6,8 @@
 <script src="{% static 'dist/enterprise/rac/index.js' %}?version={{ version }}" type="module"></script>
 <meta name="theme-color" content="#18191a" media="(prefers-color-scheme: dark)">
 <meta name="theme-color" content="#ffffff" media="(prefers-color-scheme: light)">
-<link rel="icon" href="{{ tenant.branding_favicon }}">
-<link rel="shortcut icon" href="{{ tenant.branding_favicon }}">
+<link rel="icon" href="{{ tenant.branding_favicon_url }}">
+<link rel="shortcut icon" href="{{ tenant.branding_favicon_url }}">
 {% include "base/header_js.html" %}
 {% endblock %}
 
diff --git a/authentik/lib/default.yml b/authentik/lib/default.yml
index 22eda58ae872..4b68d7f1021a 100644
--- a/authentik/lib/default.yml
+++ b/authentik/lib/default.yml
@@ -122,6 +122,7 @@ web:
   # No default here as it's set dynamically
   # workers: 2
   threads: 4
+  path: /
 
 worker:
   concurrency: 2
diff --git a/authentik/lib/sentry.py b/authentik/lib/sentry.py
index b42a299660f2..ceec11ffd378 100644
--- a/authentik/lib/sentry.py
+++ b/authentik/lib/sentry.py
@@ -36,6 +36,7 @@
 from authentik.lib.utils.reflection import get_env
 
 LOGGER = get_logger()
+_root_path = CONFIG.get("web.path", "/")
 
 
 class SentryIgnoredException(Exception):
@@ -89,7 +90,7 @@ def traces_sampler(sampling_context: dict) -> float:
     path = sampling_context.get("asgi_scope", {}).get("path", "")
     _type = sampling_context.get("asgi_scope", {}).get("type", "")
     # Ignore all healthcheck routes
-    if path.startswith("/-/health") or path.startswith("/-/metrics"):
+    if path.startswith(f"{_root_path}-/health") or path.startswith(f"{_root_path}-/metrics"):
         return 0
     if _type == "websocket":
         return 0
diff --git a/authentik/root/settings.py b/authentik/root/settings.py
index 11d847b6ce9f..f6f7480e3903 100644
--- a/authentik/root/settings.py
+++ b/authentik/root/settings.py
@@ -31,6 +31,8 @@
 # Custom user model
 AUTH_USER_MODEL = "authentik_core.User"
 
+CSRF_COOKIE_PATH = LANGUAGE_COOKIE_PATH = SESSION_COOKIE_PATH = CONFIG.get("web.path", "/")
+
 CSRF_COOKIE_NAME = "authentik_csrf"
 CSRF_HEADER_NAME = "HTTP_X_AUTHENTIK_CSRF"
 LANGUAGE_COOKIE_NAME = "authentik_language"
@@ -389,7 +391,7 @@
 # https://docs.djangoproject.com/en/2.1/howto/static-files/
 
 STATICFILES_DIRS = [BASE_DIR / Path("web")]
-STATIC_URL = "/static/"
+STATIC_URL = CONFIG.get("web.path", "/") + "static/"
 
 STORAGES = {
     "staticfiles": {
diff --git a/authentik/root/urls.py b/authentik/root/urls.py
index 1b03051fddd6..b12eb83168da 100644
--- a/authentik/root/urls.py
+++ b/authentik/root/urls.py
@@ -4,6 +4,7 @@
 from structlog.stdlib import get_logger
 
 from authentik.core.views import error
+from authentik.lib.config import CONFIG
 from authentik.lib.utils.reflection import get_apps
 from authentik.root.monitoring import LiveView, MetricsView, ReadyView
 
@@ -14,7 +15,7 @@
 handler404 = error.NotFoundView.as_view()
 handler500 = error.ServerErrorView.as_view()
 
-urlpatterns = []
+_urlpatterns = []
 
 for _authentik_app in get_apps():
     mountpoints = None
@@ -35,7 +36,7 @@
                 namespace=namespace,
             ),
         )
-        urlpatterns.append(_path)
+        _urlpatterns.append(_path)
         LOGGER.debug(
             "Mounted URLs",
             app_name=_authentik_app.name,
@@ -43,8 +44,10 @@
             namespace=namespace,
         )
 
-urlpatterns += [
+_urlpatterns += [
     path("-/metrics/", MetricsView.as_view(), name="metrics"),
     path("-/health/live/", LiveView.as_view(), name="health-live"),
     path("-/health/ready/", ReadyView.as_view(), name="health-ready"),
 ]
+
+urlpatterns = [path(CONFIG.get("web.path", "/")[1:], include(_urlpatterns))]
diff --git a/authentik/root/websocket.py b/authentik/root/websocket.py
index 560a39c07d02..70fec2055fa0 100644
--- a/authentik/root/websocket.py
+++ b/authentik/root/websocket.py
@@ -2,13 +2,16 @@
 
 from importlib import import_module
 
+from channels.routing import URLRouter
+from django.urls import path
 from structlog.stdlib import get_logger
 
+from authentik.lib.config import CONFIG
 from authentik.lib.utils.reflection import get_apps
 
 LOGGER = get_logger()
 
-websocket_urlpatterns = []
+_websocket_urlpatterns = []
 for _authentik_app in get_apps():
     try:
         api_urls = import_module(f"{_authentik_app.name}.urls")
@@ -17,8 +20,15 @@
     if not hasattr(api_urls, "websocket_urlpatterns"):
         continue
     urls: list = api_urls.websocket_urlpatterns
-    websocket_urlpatterns.extend(urls)
+    _websocket_urlpatterns.extend(urls)
     LOGGER.debug(
         "Mounted Websocket URLs",
         app_name=_authentik_app.name,
     )
+
+websocket_urlpatterns = [
+    path(
+        CONFIG.get("web.path", "/")[1:],
+        URLRouter(_websocket_urlpatterns),
+    ),
+]
diff --git a/cmd/server/healthcheck.go b/cmd/server/healthcheck.go
index d37cfb483eb4..0f0ccec02929 100644
--- a/cmd/server/healthcheck.go
+++ b/cmd/server/healthcheck.go
@@ -47,7 +47,7 @@ func checkServer() int {
 	h := &http.Client{
 		Transport: web.NewUserAgentTransport("goauthentik.io/healthcheck", http.DefaultTransport),
 	}
-	url := fmt.Sprintf("http://%s/-/health/live/", config.Get().Listen.HTTP)
+	url := fmt.Sprintf("http://%s%s-/health/live/", config.Get().Listen.HTTP, config.Get().Web.Path)
 	res, err := h.Head(url)
 	if err != nil {
 		log.WithError(err).Warning("failed to send healthcheck request")
diff --git a/cmd/server/server.go b/cmd/server/server.go
index 9324c984de46..a09451d1258f 100644
--- a/cmd/server/server.go
+++ b/cmd/server/server.go
@@ -61,7 +61,7 @@ var rootCmd = &cobra.Command{
 		ex := common.Init()
 		defer common.Defer()
 
-		u, err := url.Parse(fmt.Sprintf("http://%s", config.Get().Listen.HTTP))
+		u, err := url.Parse(fmt.Sprintf("http://%s%s", config.Get().Listen.HTTP, config.Get().Web.Path))
 		if err != nil {
 			panic(err)
 		}
diff --git a/internal/config/struct.go b/internal/config/struct.go
index 0964d7583c2d..c0cd6d8e8e37 100644
--- a/internal/config/struct.go
+++ b/internal/config/struct.go
@@ -14,6 +14,7 @@ type Config struct {
 	// Config for both core and outposts
 	Debug  bool         `yaml:"debug" env:"AUTHENTIK_DEBUG, overwrite"`
 	Listen ListenConfig `yaml:"listen" env:", prefix=AUTHENTIK_LISTEN__"`
+	Web    WebConfig    `yaml:"web" env:", prefix=AUTHENTIK__WEB__"`
 
 	// Outpost specific config
 	// These are only relevant for proxy/ldap outposts, and cannot be set via YAML
@@ -71,3 +72,7 @@ type OutpostConfig struct {
 	Discover               bool   `yaml:"discover" env:"DISCOVER, overwrite"`
 	DisableEmbeddedOutpost bool   `yaml:"disable_embedded_outpost" env:"DISABLE_EMBEDDED_OUTPOST, overwrite"`
 }
+
+type WebConfig struct {
+	Path string `yaml:"path" env:"PATH, overwrite"`
+}
diff --git a/internal/outpost/ak/api.go b/internal/outpost/ak/api.go
index 1f744010a771..cfdc0cd1a99d 100644
--- a/internal/outpost/ak/api.go
+++ b/internal/outpost/ak/api.go
@@ -54,10 +54,10 @@ type APIController struct {
 func NewAPIController(akURL url.URL, token string) *APIController {
 	rsp := sentry.StartSpan(context.Background(), "authentik.outposts.init")
 
-	config := api.NewConfiguration()
-	config.Host = akURL.Host
-	config.Scheme = akURL.Scheme
-	config.HTTPClient = &http.Client{
+	apiConfig := api.NewConfiguration()
+	apiConfig.Host = akURL.Host
+	apiConfig.Scheme = akURL.Scheme
+	apiConfig.HTTPClient = &http.Client{
 		Transport: web.NewUserAgentTransport(
 			constants.OutpostUserAgent(),
 			web.NewTracingTransport(
@@ -66,10 +66,15 @@ func NewAPIController(akURL url.URL, token string) *APIController {
 			),
 		),
 	}
-	config.AddDefaultHeader("Authorization", fmt.Sprintf("Bearer %s", token))
+	apiConfig.Servers = api.ServerConfigurations{
+		{
+			URL: fmt.Sprintf("%sapi/v3", akURL.Path),
+		},
+	}
+	apiConfig.AddDefaultHeader("Authorization", fmt.Sprintf("Bearer %s", token))
 
 	// create the API client, with the transport
-	apiClient := api.NewAPIClient(config)
+	apiClient := api.NewAPIClient(apiConfig)
 
 	log := log.WithField("logger", "authentik.outpost.ak-api-controller")
 
diff --git a/internal/outpost/ak/api_ws.go b/internal/outpost/ak/api_ws.go
index c48cebba3734..008b2927fc7e 100644
--- a/internal/outpost/ak/api_ws.go
+++ b/internal/outpost/ak/api_ws.go
@@ -17,7 +17,7 @@ import (
 )
 
 func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error {
-	pathTemplate := "%s://%s/ws/outpost/%s/?%s"
+	pathTemplate := "%s://%s%sws/outpost/%s/?%s"
 	query := akURL.Query()
 	query.Set("instance_uuid", ac.instanceUUID.String())
 	scheme := strings.ReplaceAll(akURL.Scheme, "http", "ws")
@@ -37,7 +37,7 @@ func (ac *APIController) initWS(akURL url.URL, outpostUUID string) error {
 		},
 	}
 
-	ws, _, err := dialer.Dial(fmt.Sprintf(pathTemplate, scheme, akURL.Host, outpostUUID, akURL.Query().Encode()), header)
+	ws, _, err := dialer.Dial(fmt.Sprintf(pathTemplate, scheme, akURL.Host, akURL.Path, outpostUUID, akURL.Query().Encode()), header)
 	if err != nil {
 		ac.logger.WithError(err).Warning("failed to connect websocket")
 		return err
@@ -83,6 +83,7 @@ func (ac *APIController) reconnectWS() {
 	u := url.URL{
 		Host:   ac.Client.GetConfig().Host,
 		Scheme: ac.Client.GetConfig().Scheme,
+		Path:   strings.ReplaceAll(ac.Client.GetConfig().Servers[0].URL, "api/v3", ""),
 	}
 	attempt := 1
 	for {
diff --git a/internal/web/metrics.go b/internal/web/metrics.go
index baf486e26d18..e816a99e259d 100644
--- a/internal/web/metrics.go
+++ b/internal/web/metrics.go
@@ -31,7 +31,7 @@ func (ws *WebServer) runMetricsServer() {
 		).ServeHTTP(rw, r)
 
 		// Get upstream metrics
-		re, err := http.NewRequest("GET", fmt.Sprintf("%s/-/metrics/", ws.ul.String()), nil)
+		re, err := http.NewRequest("GET", fmt.Sprintf("%s%s-/metrics/", ws.upstreamURL.String(), config.Get().Web.Path), nil)
 		if err != nil {
 			l.WithError(err).Warning("failed to get upstream metrics")
 			return
diff --git a/internal/web/proxy.go b/internal/web/proxy.go
index 56f81a0726cc..8961fc7d096a 100644
--- a/internal/web/proxy.go
+++ b/internal/web/proxy.go
@@ -9,14 +9,15 @@ import (
 	"time"
 
 	"github.com/prometheus/client_golang/prometheus"
+	"goauthentik.io/internal/config"
 	"goauthentik.io/internal/utils/sentry"
 )
 
 func (ws *WebServer) configureProxy() {
 	// Reverse proxy to the application server
 	director := func(req *http.Request) {
-		req.URL.Scheme = ws.ul.Scheme
-		req.URL.Host = ws.ul.Host
+		req.URL.Scheme = ws.upstreamURL.Scheme
+		req.URL.Host = ws.upstreamURL.Host
 		if _, ok := req.Header["User-Agent"]; !ok {
 			// explicitly disable User-Agent so it's not set to default value
 			req.Header.Set("User-Agent", "")
@@ -32,10 +33,10 @@ func (ws *WebServer) configureProxy() {
 	}
 	rp.ErrorHandler = ws.proxyErrorHandler
 	rp.ModifyResponse = ws.proxyModifyResponse
-	ws.m.Path("/-/health/live/").HandlerFunc(sentry.SentryNoSample(func(rw http.ResponseWriter, r *http.Request) {
+	ws.mainRouter.PathPrefix(config.Get().Web.Path).Path("/-/health/live/").HandlerFunc(sentry.SentryNoSample(func(rw http.ResponseWriter, r *http.Request) {
 		rw.WriteHeader(204)
 	}))
-	ws.m.PathPrefix("/").HandlerFunc(sentry.SentryNoSample(func(rw http.ResponseWriter, r *http.Request) {
+	ws.mainRouter.PathPrefix(config.Get().Web.Path).HandlerFunc(sentry.SentryNoSample(func(rw http.ResponseWriter, r *http.Request) {
 		if !ws.g.IsRunning() {
 			ws.proxyErrorHandler(rw, r, errors.New("authentik starting"))
 			return
diff --git a/internal/web/static.go b/internal/web/static.go
index 1d6d1888ec75..0ce81939c47c 100644
--- a/internal/web/static.go
+++ b/internal/web/static.go
@@ -14,41 +14,71 @@ import (
 )
 
 func (ws *WebServer) configureStatic() {
-	statRouter := ws.lh.NewRoute().Subrouter()
-	statRouter.Use(ws.staticHeaderMiddleware)
-	indexLessRouter := statRouter.NewRoute().Subrouter()
+	// Setup routers
+	staticRouter := ws.loggingRouter.NewRoute().Subrouter()
+	staticRouter.Use(ws.staticHeaderMiddleware)
+	indexLessRouter := staticRouter.NewRoute().Subrouter()
+	// Specifically disable index
 	indexLessRouter.Use(web.DisableIndex)
+
 	distFs := http.FileServer(http.Dir("./web/dist"))
-	distHandler := http.StripPrefix("/static/dist/", distFs)
-	authentikHandler := http.StripPrefix("/static/authentik/", http.FileServer(http.Dir("./web/authentik")))
+
+	pathStripper := func(handler http.Handler, paths ...string) http.Handler {
+		h := handler
+		for _, path := range paths {
+			h = http.StripPrefix(path, h)
+		}
+		return h
+	}
+
 	helpHandler := http.FileServer(http.Dir("./website/help/"))
-	indexLessRouter.PathPrefix("/static/dist/").Handler(distHandler)
-	indexLessRouter.PathPrefix("/static/authentik/").Handler(authentikHandler)
 
-	// Prevent font-loading issues on safari, which loads fonts relatively to the URL the browser is on
-	indexLessRouter.PathPrefix("/if/flow/{flow_slug}/assets").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+	indexLessRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/static/dist/").Handler(pathStripper(
+		distFs,
+		"static/dist/",
+		config.Get().Web.Path,
+	))
+	indexLessRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/static/authentik/").Handler(pathStripper(
+		http.FileServer(http.Dir("./web/authentik")),
+		"static/authentik/",
+		config.Get().Web.Path,
+	))
+
+	indexLessRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/if/flow/{flow_slug}/assets").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 		vars := mux.Vars(r)
 
-		web.DisableIndex(http.StripPrefix(fmt.Sprintf("/if/flow/%s", vars["flow_slug"]), distFs)).ServeHTTP(rw, r)
+		pathStripper(
+			distFs,
+			"if/flow/"+vars["flow_slug"],
+			config.Get().Web.Path,
+		).ServeHTTP(rw, r)
 	})
-	indexLessRouter.PathPrefix("/if/admin/assets").Handler(http.StripPrefix("/if/admin", distFs))
-	indexLessRouter.PathPrefix("/if/user/assets").Handler(http.StripPrefix("/if/user", distFs))
-	indexLessRouter.PathPrefix("/if/rac/{app_slug}/assets").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+	indexLessRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/if/admin/assets").Handler(http.StripPrefix(fmt.Sprintf("%sif/admin", config.Get().Web.Path), distFs))
+	indexLessRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/if/user/assets").Handler(http.StripPrefix(fmt.Sprintf("%sif/user", config.Get().Web.Path), distFs))
+	indexLessRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/if/rac/{app_slug}/assets").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 		vars := mux.Vars(r)
 
-		web.DisableIndex(http.StripPrefix(fmt.Sprintf("/if/rac/%s", vars["app_slug"]), distFs)).ServeHTTP(rw, r)
+		pathStripper(
+			distFs,
+			"if/rac/"+vars["app_slug"],
+			config.Get().Web.Path,
+		).ServeHTTP(rw, r)
 	})
 
 	// Media files, if backend is file
 	if config.Get().Storage.Media.Backend == "file" {
 		fsMedia := http.FileServer(http.Dir(config.Get().Storage.Media.File.Path))
-		indexLessRouter.PathPrefix("/media/").Handler(http.StripPrefix("/media", fsMedia))
+		indexLessRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/media/").Handler(http.StripPrefix("/media", fsMedia))
 	}
 
-	statRouter.PathPrefix("/if/help/").Handler(http.StripPrefix("/if/help/", helpHandler))
-	statRouter.PathPrefix("/help").Handler(http.RedirectHandler("/if/help/", http.StatusMovedPermanently))
+	staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/if/help/").Handler(pathStripper(
+		helpHandler,
+		config.Get().Web.Path,
+		"/if/help/",
+	))
+	staticRouter.PathPrefix(config.Get().Web.Path).PathPrefix("/help").Handler(http.RedirectHandler(fmt.Sprintf("%sif/help/", config.Get().Web.Path), http.StatusMovedPermanently))
 
-	ws.lh.Path("/robots.txt").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+	staticRouter.PathPrefix(config.Get().Web.Path).Path("/robots.txt").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 		rw.Header()["Content-Type"] = []string{"text/plain"}
 		rw.WriteHeader(200)
 		_, err := rw.Write(staticWeb.RobotsTxt)
@@ -56,7 +86,7 @@ func (ws *WebServer) configureStatic() {
 			ws.log.WithError(err).Warning("failed to write response")
 		}
 	})
-	ws.lh.Path("/.well-known/security.txt").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
+	staticRouter.PathPrefix(config.Get().Web.Path).Path("/.well-known/security.txt").HandlerFunc(func(rw http.ResponseWriter, r *http.Request) {
 		rw.Header()["Content-Type"] = []string{"text/plain"}
 		rw.WriteHeader(200)
 		_, err := rw.Write(staticWeb.SecurityTxt)
diff --git a/internal/web/web.go b/internal/web/web.go
index cce90e6c5e0b..0d91d0eabe91 100644
--- a/internal/web/web.go
+++ b/internal/web/web.go
@@ -32,13 +32,13 @@ type WebServer struct {
 	ProxyServer *proxyv2.ProxyServer
 	BrandTLS    *brand_tls.Watcher
 
-	g   *gounicorn.GoUnicorn
-	gr  bool
-	m   *mux.Router
-	lh  *mux.Router
-	log *log.Entry
-	uc  *http.Client
-	ul  *url.URL
+	g              *gounicorn.GoUnicorn
+	gunicornReady  bool
+	mainRouter     *mux.Router
+	loggingRouter  *mux.Router
+	log            *log.Entry
+	upstreamClient *http.Client
+	upstreamURL    *url.URL
 }
 
 const UnixSocketName = "authentik-core.sock"
@@ -72,17 +72,22 @@ func NewWebServer() *WebServer {
 	u, _ := url.Parse("http://localhost:8000")
 
 	ws := &WebServer{
-		m:   mainHandler,
-		lh:  loggingHandler,
-		log: l,
-		gr:  true,
-		uc:  upstreamClient,
-		ul:  u,
+		mainRouter:     mainHandler,
+		loggingRouter:  loggingHandler,
+		log:            l,
+		gunicornReady:  true,
+		upstreamClient: upstreamClient,
+		upstreamURL:    u,
 	}
 	ws.configureStatic()
 	ws.configureProxy()
+	// Redirect for sub-folder
+	if sp := config.Get().Web.Path; sp != "/" {
+		ws.mainRouter.Path("/").Handler(http.RedirectHandler(sp, http.StatusFound))
+	}
+	hcUrl := fmt.Sprintf("%s%s-/health/live/", ws.upstreamURL.String(), config.Get().Web.Path)
 	ws.g = gounicorn.New(func() bool {
-		req, err := http.NewRequest(http.MethodGet, fmt.Sprintf("%s/-/health/live/", ws.ul.String()), nil)
+		req, err := http.NewRequest(http.MethodGet, hcUrl, nil)
 		if err != nil {
 			ws.log.WithError(err).Warning("failed to create request for healthcheck")
 			return false
@@ -106,7 +111,7 @@ func (ws *WebServer) Start() {
 
 func (ws *WebServer) attemptStartBackend() {
 	for {
-		if !ws.gr {
+		if !ws.gunicornReady {
 			return
 		}
 		err := ws.g.Start()
@@ -134,7 +139,7 @@ func (ws *WebServer) Core() *gounicorn.GoUnicorn {
 }
 
 func (ws *WebServer) upstreamHttpClient() *http.Client {
-	return ws.uc
+	return ws.upstreamClient
 }
 
 func (ws *WebServer) Shutdown() {
@@ -159,7 +164,7 @@ func (ws *WebServer) listenPlain() {
 
 func (ws *WebServer) serve(listener net.Listener) {
 	srv := &http.Server{
-		Handler: ws.m,
+		Handler: ws.mainRouter,
 	}
 
 	// See https://golang.org/pkg/net/http/#Server.Shutdown
diff --git a/web/src/common/api/config.ts b/web/src/common/api/config.ts
index dd1a2c1b75e9..8187a974b9da 100644
--- a/web/src/common/api/config.ts
+++ b/web/src/common/api/config.ts
@@ -68,7 +68,7 @@ export function getMetaContent(key: string): string {
 }
 
 export const DEFAULT_CONFIG = new Configuration({
-    basePath: (process.env.AK_API_BASE_PATH || window.location.origin) + "/api/v3",
+    basePath: `${globalAK().api.base}api/v3`,
     headers: {
         "sentry-trace": getMetaContent("sentry-trace"),
     },
diff --git a/web/src/common/global.ts b/web/src/common/global.ts
index 990303df0d43..7e6046def236 100644
--- a/web/src/common/global.ts
+++ b/web/src/common/global.ts
@@ -11,6 +11,9 @@ export interface GlobalAuthentik {
     versionFamily: string;
     versionSubdomain: string;
     build: string;
+    api: {
+        base: string;
+    };
 }
 
 export interface AuthentikWindow {
@@ -35,6 +38,9 @@ export function globalAK(): GlobalAuthentik {
             versionFamily: "",
             versionSubdomain: "",
             build: "",
+            api: {
+                base: process.env.AK_API_BASE_PATH || window.location.origin,
+            },
         };
     }
     return ak;
diff --git a/web/src/common/ws.ts b/web/src/common/ws.ts
index 29823d603bcb..86605cfc3733 100644
--- a/web/src/common/ws.ts
+++ b/web/src/common/ws.ts
@@ -1,3 +1,4 @@
+import { globalAK } from "@goauthentik/app/common/global";
 import { EVENT_MESSAGE, EVENT_WS_MESSAGE } from "@goauthentik/common/constants";
 import { MessageLevel } from "@goauthentik/common/messages";
 
@@ -21,9 +22,8 @@ export class WebsocketClient {
 
     connect(): void {
         if (navigator.webdriver) return;
-        const wsUrl = `${window.location.protocol.replace("http", "ws")}//${
-            window.location.host
-        }/ws/client/`;
+        const apiURL = new URL(globalAK().api.base);
+        const wsUrl = `${window.location.protocol.replace("http", "ws")}//${apiURL.host}${apiURL.pathname}ws/client/`;
         this.messageSocket = new WebSocket(wsUrl);
         this.messageSocket.addEventListener("open", () => {
             console.debug(`authentik/ws: connected to ${wsUrl}`);
diff --git a/web/src/elements/enterprise/EnterpriseStatusBanner.ts b/web/src/elements/enterprise/EnterpriseStatusBanner.ts
index b3360fb59a31..5ba528d4e7ae 100644
--- a/web/src/elements/enterprise/EnterpriseStatusBanner.ts
+++ b/web/src/elements/enterprise/EnterpriseStatusBanner.ts
@@ -1,3 +1,4 @@
+import { globalAK } from "@goauthentik/app/common/global";
 import { AKElement } from "@goauthentik/elements/Base";
 import { WithLicenseSummary } from "@goauthentik/elements/Interface/licenseSummaryProvider";
 
@@ -21,7 +22,9 @@ export class EnterpriseStatusBanner extends WithLicenseSummary(AKElement) {
             class="pf-c-banner ${this.licenseSummary?.readOnly ? "pf-m-red" : "pf-m-gold"}"
         >
             ${msg("Warning: The current user count has exceeded the configured licenses.")}
-            <a href="/if/admin/#/enterprise/licenses"> ${msg("Click here for more info.")} </a>
+            <a href="${globalAK().api.base}if/admin/#/enterprise/licenses">
+                ${msg("Click here for more info.")}
+            </a>
         </div>`;
     }
 
diff --git a/web/src/elements/notifications/APIDrawer.ts b/web/src/elements/notifications/APIDrawer.ts
index 20b56f17dfda..d166eada30b1 100644
--- a/web/src/elements/notifications/APIDrawer.ts
+++ b/web/src/elements/notifications/APIDrawer.ts
@@ -1,3 +1,4 @@
+import { globalAK } from "@goauthentik/app/common/global";
 import { RequestInfo } from "@goauthentik/common/api/middleware";
 import { EVENT_API_DRAWER_TOGGLE, EVENT_REQUEST_POST } from "@goauthentik/common/constants";
 import { AKElement } from "@goauthentik/elements/Base";
@@ -86,7 +87,9 @@ export class APIDrawer extends AKElement {
                         <h1 class="pf-c-notification-drawer__header-title">
                             ${msg("API Requests")}
                         </h1>
-                        <a href="/api/v3/" target="_blank">${msg("Open API Browser")}</a>
+                        <a href="${globalAK().api.base}api/v3/" target="_blank"
+                            >${msg("Open API Browser")}</a
+                        >
                     </div>
                     <div class="pf-c-notification-drawer__header-action">
                         <div class="pf-c-notification-drawer__header-action-close">
diff --git a/web/src/elements/notifications/NotificationDrawer.ts b/web/src/elements/notifications/NotificationDrawer.ts
index 114df30862ff..1dbfe5d122ff 100644
--- a/web/src/elements/notifications/NotificationDrawer.ts
+++ b/web/src/elements/notifications/NotificationDrawer.ts
@@ -1,3 +1,4 @@
+import { globalAK } from "@goauthentik/app/common/global";
 import { getRelativeTime } from "@goauthentik/app/common/utils";
 import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 import { EVENT_NOTIFICATION_DRAWER_TOGGLE, EVENT_REFRESH } from "@goauthentik/common/constants";
@@ -100,7 +101,7 @@ export class NotificationDrawer extends AKElement {
                 html`
                     <a
                         class="pf-c-dropdown__toggle pf-m-plain"
-                        href="/if/admin/#/events/log/${item.event?.pk}"
+                        href="${globalAK().api.base}if/admin/#/events/log/${item.event?.pk}"
                     >
                         <pf-tooltip position="top" content=${msg("Show details")}>
                             <i class="fas fa-share-square"></i>
diff --git a/web/src/elements/sidebar/SidebarUser.ts b/web/src/elements/sidebar/SidebarUser.ts
index 97cdad27bc5d..7d6a3f4daa98 100644
--- a/web/src/elements/sidebar/SidebarUser.ts
+++ b/web/src/elements/sidebar/SidebarUser.ts
@@ -1,3 +1,4 @@
+import { globalAK } from "@goauthentik/app/common/global";
 import { me } from "@goauthentik/common/users";
 import { AKElement } from "@goauthentik/elements/Base";
 
@@ -35,7 +36,11 @@ export class SidebarUser extends AKElement {
 
     render(): TemplateResult {
         return html`
-            <a href="/if/user/#/settings" class="pf-c-nav__link user-avatar" id="user-settings">
+            <a
+                href="${globalAK().api.base}if/user/#/settings"
+                class="pf-c-nav__link user-avatar"
+                id="user-settings"
+            >
                 ${until(
                     me().then((u) => {
                         return html`<img
@@ -47,7 +52,11 @@ export class SidebarUser extends AKElement {
                     html``,
                 )}
             </a>
-            <a href="/flows/-/default/invalidation/" class="pf-c-nav__link user-logout" id="logout">
+            <a
+                href="${globalAK().api.base}flows/-/default/invalidation/"
+                class="pf-c-nav__link user-logout"
+                id="logout"
+            >
                 <i class="fas fa-sign-out-alt" aria-hidden="true"></i>
             </a>
         `;
diff --git a/web/src/user/LibraryApplication/index.ts b/web/src/user/LibraryApplication/index.ts
index 35f60804fb3a..18c42d53f319 100644
--- a/web/src/user/LibraryApplication/index.ts
+++ b/web/src/user/LibraryApplication/index.ts
@@ -1,3 +1,4 @@
+import { globalAK } from "@goauthentik/app/common/global";
 import { PFSize } from "@goauthentik/app/elements/Spinner";
 import { truncateWords } from "@goauthentik/common/utils";
 import "@goauthentik/components/ak-app-icon";
@@ -77,7 +78,8 @@ export class LibraryApplication extends AKElement {
                 ? html`
                       <a
                           class="pf-c-button pf-m-control pf-m-small pf-m-block"
-                          href="/if/admin/#/core/applications/${application?.slug}"
+                          href="${globalAK().api
+                              .base}if/admin/#/core/applications/${application?.slug}"
                       >
                           <i class="fas fa-edit"></i>&nbsp;${msg("Edit")}
                       </a>
diff --git a/web/src/user/LibraryPage/ApplicationEmptyState.ts b/web/src/user/LibraryPage/ApplicationEmptyState.ts
index 11871c1396c6..bc2a2b7dd224 100644
--- a/web/src/user/LibraryPage/ApplicationEmptyState.ts
+++ b/web/src/user/LibraryPage/ApplicationEmptyState.ts
@@ -1,4 +1,4 @@
-import { docLink } from "@goauthentik/common/global";
+import { docLink, globalAK } from "@goauthentik/common/global";
 import { adaptCSS } from "@goauthentik/common/utils";
 import { AKElement } from "@goauthentik/elements/Base";
 import { paramURL } from "@goauthentik/elements/router/RouterOutlet";
@@ -50,7 +50,7 @@ export class LibraryPageApplicationEmptyList extends AKElement {
                 <a
                     aria-disabled="false"
                     class="cta pf-c-button pf-m-secondary"
-                    href="/if/admin/${href}"
+                    href="${globalAK().api.base}if/admin/${href}"
                     >${msg("Create a new application")}</a
                 >
             </div>
diff --git a/web/src/user/UserInterface.ts b/web/src/user/UserInterface.ts
index 53cdf15c9a3f..99bc940d7b0d 100644
--- a/web/src/user/UserInterface.ts
+++ b/web/src/user/UserInterface.ts
@@ -1,3 +1,4 @@
+import { globalAK } from "@goauthentik/app/common/global";
 import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 import {
     EVENT_API_DRAWER_TOGGLE,
@@ -277,7 +278,7 @@ export class UserInterface extends EnterpriseAwareInterface {
                                 : html``}
                             <div class="pf-c-page__header-tools-item">
                                 <a
-                                    href="/flows/-/default/invalidation/"
+                                    href="${globalAK().api.base}flows/-/default/invalidation/"
                                     class="pf-c-button pf-m-plain"
                                 >
                                     <pf-tooltip position="top" content=${msg("Sign out")}>
@@ -288,7 +289,7 @@ export class UserInterface extends EnterpriseAwareInterface {
                             ${canAccessAdmin
                                 ? html`<a
                                       class="pf-c-button pf-m-secondary pf-m-small pf-u-display-none pf-u-display-block-on-md"
-                                      href="/if/admin/"
+                                      href="${globalAK().api.base}if/admin/"
                                   >
                                       ${msg("Admin interface")}
                                   </a>`
diff --git a/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts b/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts
index 40cd5bcfd5f7..534e4d23a138 100644
--- a/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts
+++ b/web/src/user/user-settings/details/UserSettingsFlowExecutor.ts
@@ -1,3 +1,4 @@
+import { globalAK } from "@goauthentik/app/common/global";
 import { DEFAULT_CONFIG } from "@goauthentik/common/api/config";
 import { EVENT_REFRESH } from "@goauthentik/common/constants";
 import { MessageLevel } from "@goauthentik/common/messages";
@@ -180,7 +181,10 @@ export class UserSettingsFlowExecutor
                             `authentik/user/flows: unsupported stage type ${this.challenge.component}`,
                         );
                         return html`
-                            <a href="/if/flow/${this.flowSlug}" class="pf-c-button pf-m-primary">
+                            <a
+                                href="${globalAK().api.base}if/flow/${this.flowSlug}"
+                                class="pf-c-button pf-m-primary"
+                            >
                                 ${msg("Open settings")}
                             </a>
                         `;
diff --git a/web/src/user/user-settings/details/stages/prompt/PromptStage.ts b/web/src/user/user-settings/details/stages/prompt/PromptStage.ts
index 9276f8a2fe68..0c6e75dd1936 100644
--- a/web/src/user/user-settings/details/stages/prompt/PromptStage.ts
+++ b/web/src/user/user-settings/details/stages/prompt/PromptStage.ts
@@ -1,3 +1,4 @@
+import { globalAK } from "@goauthentik/app/common/global";
 import "@goauthentik/elements/forms/HorizontalFormElement";
 import { PromptStage } from "@goauthentik/flow/stages/prompt/PromptStage";
 
@@ -54,7 +55,8 @@ export class UserSettingsPromptStage extends PromptStage {
                     ${this.host.brand?.flowUnenrollment
                         ? html` <a
                               class="pf-c-button pf-m-danger"
-                              href="/if/flow/${this.host.brand.flowUnenrollment}/"
+                              href="${globalAK().api.base}if/flow/${this.host.brand
+                                  .flowUnenrollment}/"
                           >
                               ${msg("Delete account")}
                           </a>`
diff --git a/website/docs/installation/configuration.mdx b/website/docs/installation/configuration.mdx
index e994837c089d..630656d123e9 100644
--- a/website/docs/installation/configuration.mdx
+++ b/website/docs/installation/configuration.mdx
@@ -423,6 +423,16 @@ Configure Celery worker concurrency for authentik worker (see https://docs.celer
 
 Defaults to 2.
 
+### `AUTHENTIK_WEB__PATH`
+
+:::info
+Requires authentik 2024.4
+:::
+
+Configure the path under which authentik is serverd. For example to access authentik under `https://my.domain/authentik/`, set this to `/authentik/`. Value _must_ contain both a leading and trailing slash.
+
+Defaults to `/`.
+
 ## System settings
 
 :::info
diff --git a/website/docs/installation/reverse-proxy.md b/website/docs/installation/reverse-proxy.md
index c7931690ba5a..12c289ec0769 100644
--- a/website/docs/installation/reverse-proxy.md
+++ b/website/docs/installation/reverse-proxy.md
@@ -51,6 +51,8 @@ server {
     add_header Strict-Transport-Security "max-age=63072000" always;
 
     # Proxy site
+    # Location can be set to a subpath if desired, see documentation linked below:
+    # https://goauthentik.io/docs/installation/configuration#authentik_web__path
     location / {
         proxy_pass https://authentik;
         proxy_http_version 1.1;


Code review comments:
diff_hunk: @@ -14,6 +14,7 @@ type Config struct {
 	// Config for both core and outposts
 	Debug  bool         `yaml:"debug" env:"AUTHENTIK_DEBUG, overwrite"`
 	Listen ListenConfig `yaml:"listen" env:", prefix=AUTHENTIK_LISTEN__"`
+	Web    WebConfig    `yaml:"web" env:", prefix=AUTHENTIK__WEB__"`
path: internal/config/struct.go
text: @user1:
```suggestion
	Web    WebConfig    `yaml:"web" env:", prefix=AUTHENTIK_WEB__"`
```