diff --git a/.php-cs-fixer.dist.php b/.php-cs-fixer.dist.php
index 4de9e583dca5676f5bda4cf2b7af9fe24ea41abb..b9779195031342161f155990574b61e2c69dd731 100644
--- a/.php-cs-fixer.dist.php
+++ b/.php-cs-fixer.dist.php
@@ -18,6 +18,7 @@ $config->setRules([
     'strict_param' => true,
     'declare_strict_types' => true,
     'method_argument_space' => ['on_multiline' => 'ignore'],
+    'phpdoc_to_comment' => false,
 ])
 ->setRiskyAllowed(true)
 ->setFinder($finder);
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9f61eee93514452f8f6c7f26032d96b572636f8e..95de8a851e2dad146284f4e702cc93e3a790577a 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,8 @@
+# v0.1.52
+
+* new Locale service for setting a locale from a requests and forwarding
+  to other services
+
 # v0.1.45
 
 * dbp:relay:core:migrate: Work around issues in DoctrineMigrationsBundle which
diff --git a/composer.json b/composer.json
index 42a924cb6a3061d8f5c3643211eb07bacf3a8395..f54c7e207eb445ac4c688c257c5e767958605d9c 100644
--- a/composer.json
+++ b/composer.json
@@ -29,7 +29,8 @@
         "symfony/twig-bundle": "^5.4",
         "symfony/uid": "^5.4",
         "symfony/validator": "^5.4",
-        "symfony/yaml": "^5.4"
+        "symfony/yaml": "^5.4",
+        "ext-intl": "*"
     },
     "require-dev": {
         "brainmaestro/composer-git-hooks": "^2.8.5",
diff --git a/composer.lock b/composer.lock
index fd00a981838c0f5ed372e482ddb1aaa383e9130c..b63c9186ae289b79e8c8351bf69061f418d2f14f 100644
--- a/composer.lock
+++ b/composer.lock
@@ -4,7 +4,7 @@
         "Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
         "This file is @generated automatically"
     ],
-    "content-hash": "9c42ac8c7816cdf96cd4c2f000cabaf3",
+    "content-hash": "6ddd4889d724c8beabfe6b3b58bf35d7",
     "packages": [
         {
             "name": "api-platform/core",
@@ -9843,7 +9843,8 @@
     "platform": {
         "php": ">=7.3",
         "ext-fileinfo": "*",
-        "ext-json": "*"
+        "ext-json": "*",
+        "ext-intl": "*"
     },
     "platform-dev": [],
     "platform-overrides": {
diff --git a/src/API/UserSessionInterface.php b/src/API/UserSessionInterface.php
index ce3e3df3264c2d58765dd696d0b3fb89c234974f..8bc5b1b62dbe32cf32a0fa18779a010b7434a5d5 100644
--- a/src/API/UserSessionInterface.php
+++ b/src/API/UserSessionInterface.php
@@ -6,58 +6,37 @@ namespace Dbp\Relay\CoreBundle\API;
 
 interface UserSessionInterface
 {
-    /**
-     * This gets called with the active JWT before any of the other methods are called.
-     */
-    public function setSessionToken(?array $jwt): void;
-
     /**
      * The unique identifier of the authenticated user. Or null in case it is called
      * before the user is known or if the user is a system.
-     *
-     * Can be derived from the session token for example.
      */
     public function getUserIdentifier(): ?string;
 
-    /**
-     * Returns a list of Symfony user roles, like ['ROLE_FOOBAR'].
-     *
-     * Can be derived from the session token for example.
-     */
-    public function getUserRoles(): array;
-
     /**
      * Returns an ID represents a "session" of a user which can be used for logging. It should not be possible to
      * figure out which user is behind the ID based on the ID itself and the ID should change regularly.
      * This is useful for connecting various requests together for logging while not exposing details about the user.
-     *
-     * Can be derived from long running session IDs embedded in the token for example.
-     *
-     * Return null in case no logging ID exists
      */
-    public function getSessionLoggingId(): ?string;
+    public function getSessionLoggingId(): string;
+
+    /**
+     * @deprecated
+     */
+    public function getUserRoles(): array;
 
     /**
      * Returns a unique caching key that can be used to cache metadata related to the current user session like
      * any user metadata, authorization related information etc.
      * It should not be possible to figure out which user is behind the ID based on the ID itself and the ID should
      * change regularly (after a logout/login or a key refresh for example).
-     *
-     * For example a hashed version of the token.
-     *
-     * Return null in case no appropriate cache key exists to disable any caching.
      */
-    public function getSessionCacheKey(): ?string;
+    public function getSessionCacheKey(): string;
 
     /**
-     * Should return the duration the session is valid (as a whole, not from now) in seconds.
+     * Returns the duration the session is valid (as a whole, not from now) in seconds.
      * After the specified amount of time has passed the logging ID and the caching key should have changed.
      *
      * This is mostly useful for limiting the cache.
-     *
-     * For example the lifespan of the token.
-     *
-     * Return <0 in case that information isn't available.
      */
     public function getSessionTTL(): int;
 }
diff --git a/src/Auth/ProxyAuthenticator.php b/src/Auth/ProxyAuthenticator.php
index 7fdafe8433cc8c410a2c0766ebd1e20b0a253d21..eb80e473f49e0d4a3bdc1d040e451fa2a7d61b3d 100644
--- a/src/Auth/ProxyAuthenticator.php
+++ b/src/Auth/ProxyAuthenticator.php
@@ -19,9 +19,15 @@ class ProxyAuthenticator extends AbstractAuthenticator
      */
     private $authenticators;
 
-    public function __construct()
+    /**
+     * @var UserSession
+     */
+    private $userSession;
+
+    public function __construct(UserSession $userSession)
     {
         $this->authenticators = [];
+        $this->userSession = $userSession;
     }
 
     public function addAuthenticator(AuthenticatorInterface $sub)
@@ -54,7 +60,11 @@ class ProxyAuthenticator extends AbstractAuthenticator
         $auth = $this->getAuthenticator($request);
         assert($auth !== null);
 
-        return $auth->authenticate($request);
+        $passport = $auth->authenticate($request);
+        $provider = $passport->getAttribute('relay_user_session_provider');
+        $this->userSession->setProvider($provider);
+
+        return $passport;
     }
 
     public function onAuthenticationSuccess(Request $request, TokenInterface $token, string $firewallName): ?Response
@@ -67,6 +77,8 @@ class ProxyAuthenticator extends AbstractAuthenticator
 
     public function onAuthenticationFailure(Request $request, AuthenticationException $exception): ?Response
     {
+        $this->userSession->setProvider(null);
+
         $auth = $this->getAuthenticator($request);
         assert($auth !== null);
 
diff --git a/src/Auth/UserSession.php b/src/Auth/UserSession.php
new file mode 100644
index 0000000000000000000000000000000000000000..461d9f3630c136b2b1ce8acb6e5b1d861a19a38d
--- /dev/null
+++ b/src/Auth/UserSession.php
@@ -0,0 +1,98 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Dbp\Relay\CoreBundle\Auth;
+
+use Dbp\Relay\CoreBundle\API\UserSessionInterface;
+use Dbp\Relay\CoreBundle\API\UserSessionProviderInterface;
+use Symfony\Component\Security\Core\Security;
+use Symfony\Component\Uid\Uuid;
+
+/**
+ * This service provides user session information, either sourcing information from the active auth provider
+ * or in case it is used from the CLI or unauthenticated then it returns some reasonable defaults.
+ */
+class UserSession implements UserSessionInterface
+{
+    /**
+     * @var ?UserSessionProviderInterface
+     */
+    private $provider;
+
+    /**
+     * @var Security
+     */
+    private $security;
+
+    public function __construct(?Security $security = null)
+    {
+        $this->security = $security;
+    }
+
+    public function setProvider(?UserSessionProviderInterface $provider)
+    {
+        $this->provider = $provider;
+    }
+
+    public function getUserIdentifier(): ?string
+    {
+        if ($this->provider === null) {
+            return null;
+        }
+
+        return $this->provider->getUserIdentifier();
+    }
+
+    public function getSessionLoggingId(): string
+    {
+        $id = null;
+        if ($this->provider !== null) {
+            $id = $this->provider->getSessionLoggingId();
+        }
+        if ($id === null) {
+            $id = 'unknown';
+        }
+
+        return $id;
+    }
+
+    public function getSessionCacheKey(): string
+    {
+        $key = null;
+        if ($this->provider !== null) {
+            $key = $this->provider->getSessionCacheKey();
+        }
+        if ($key === null) {
+            $key = (Uuid::v4())->toRfc4122();
+        }
+
+        return $key;
+    }
+
+    public function getSessionTTL(): int
+    {
+        $ttl = -1;
+        if ($this->provider !== null) {
+            $ttl = $this->provider->getSessionTTL();
+        }
+        if ($ttl === -1) {
+            $ttl = 60;
+        }
+
+        return $ttl;
+    }
+
+    public function getUserRoles(): array
+    {
+        if ($this->provider === null) {
+            return [];
+        }
+        $user = $this->security->getUser();
+        if ($user === null) {
+            return [];
+        }
+
+        return $user->getRoles();
+    }
+}
diff --git a/src/Helpers/Locale.php b/src/Helpers/Locale.php
index 9b9f330232d05e13bf076492453aaa9d64334e1c..8747659aa36cf7f9eb7aa3c7554dcba1c6472936 100644
--- a/src/Helpers/Locale.php
+++ b/src/Helpers/Locale.php
@@ -6,6 +6,9 @@ namespace Dbp\Relay\CoreBundle\Helpers;
 
 use Symfony\Component\HttpFoundation\RequestStack;
 
+/**
+ * @deprecated Use Locale/Locale instead
+ */
 class Locale
 {
     public const LANGUAGE_OPTION = 'lang';
diff --git a/src/Locale/Locale.php b/src/Locale/Locale.php
new file mode 100644
index 0000000000000000000000000000000000000000..b7d9a6ddb468c07904afdbf98a261664d58de8b1
--- /dev/null
+++ b/src/Locale/Locale.php
@@ -0,0 +1,92 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Dbp\Relay\CoreBundle\Locale;
+
+use Symfony\Component\DependencyInjection\ParameterBag\ParameterBagInterface;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+/**
+ * A service which can be injected, which provides the current active language and allows setting the active
+ * language based on a query parameters.
+ *
+ * This assumes that Symfony is configured to apply the 'Accept-Language' header by default to all requests.
+ */
+class Locale
+{
+    /** @var RequestStack */
+    private $requestStack;
+
+    /**
+     * @var ParameterBagInterface
+     */
+    private $parameters;
+
+    public function __construct(RequestStack $requestStack, ParameterBagInterface $parameters)
+    {
+        $this->requestStack = $requestStack;
+        $this->parameters = $parameters;
+    }
+
+    /**
+     * Returns the primary language (in ISO 639‑1 format) for the current context.
+     * In case there is a request then the request language, otherwise the default language.
+     */
+    public function getCurrentPrimaryLanguage(): string
+    {
+        $locale = $this->getCurrentLocale();
+        $lang = \Locale::getPrimaryLanguage($locale);
+        /** @psalm-suppress RedundantCondition */
+        assert($lang !== null);
+
+        return $lang;
+    }
+
+    /**
+     * Sets the locale for the active request via a query parameter.
+     * The query parameter format is the same as the 'Accept-Language' HTTP header format.
+     * In case the query parameter isn't part of the request then nothing changes.
+     */
+    public function setCurrentRequestLocaleFromQuery(string $queryParam = 'lang'): void
+    {
+        $request = $this->requestStack->getCurrentRequest();
+        if ($request === null) {
+            throw new \RuntimeException('No active request');
+        }
+        self::setRequestLocaleFromQuery($request, $queryParam);
+    }
+
+    /**
+     * Returns the current locale, either from the active request, or the default one.
+     */
+    private function getCurrentLocale(): string
+    {
+        $request = $this->requestStack->getCurrentRequest();
+        if ($request !== null) {
+            $locale = $request->getLocale();
+        } else {
+            $locale = $this->parameters->get('kernel.default_locale');
+            assert(is_string($locale));
+        }
+
+        return $locale;
+    }
+
+    /**
+     * Same as setCurrentRequestLocaleFromQuery(), but takes a request object.
+     */
+    public static function setRequestLocaleFromQuery(Request $request, string $queryParam): void
+    {
+        if ($request->query->has($queryParam)) {
+            $lang = $request->query->get($queryParam);
+            assert(is_string($lang));
+            $locale = \Locale::acceptFromHttp($lang);
+            if ($locale === false) {
+                throw new \RuntimeException('Failed to parse Accept-Language');
+            }
+            $request->setLocale($locale);
+        }
+    }
+}
diff --git a/src/Logging/LoggingProcessor.php b/src/Logging/LoggingProcessor.php
index e014fabb08c0eac79ce40d9505dc1c347dde4233..0b6c309f90a30e5a71d495669a4278fd14943d29 100644
--- a/src/Logging/LoggingProcessor.php
+++ b/src/Logging/LoggingProcessor.php
@@ -43,10 +43,7 @@ final class LoggingProcessor
         $this->maskUserId($record);
 
         // Add a session ID (the same during multiple requests for the same user session)
-        $loggingId = $this->userDataProvider->getSessionLoggingId();
-        if ($loggingId !== null) {
-            $record['context']['relay-session-id'] = $loggingId;
-        }
+        $record['context']['relay-session-id'] = $this->userDataProvider->getSessionLoggingId();
 
         // Add a request ID (the same during the same client request)
         $request = $this->requestStack->getMainRequest();
diff --git a/src/Resources/config/services.yaml b/src/Resources/config/services.yaml
index 714322cb7f9d3ee7da5fb6161dfa21ef7a535c56..7cb009e686bece3f65e7da38a3210a6ceee71f86 100644
--- a/src/Resources/config/services.yaml
+++ b/src/Resources/config/services.yaml
@@ -58,6 +58,13 @@ services:
     autowire: true
     autoconfigure: true
 
+  Dbp\Relay\CoreBundle\Auth\UserSession:
+    autowire: true
+    autoconfigure: true
+
+  Dbp\Relay\CoreBundle\API\UserSessionInterface:
+    '@Dbp\Relay\CoreBundle\Auth\UserSession'
+
   Dbp\Relay\CoreBundle\LocalData\LocalDataAwareEventDispatcher:
     autowire: true
     autoconfigure: true
@@ -97,3 +104,7 @@ services:
   Dbp\Relay\CoreBundle\ProxyApi\ProxyDataEventSubscriber:
     autowire: true
     autoconfigure: true
+
+  Dbp\Relay\CoreBundle\Locale\Locale:
+    autowire: true
+    autoconfigure: true
diff --git a/src/TestUtils/TestUserSession.php b/src/TestUtils/TestUserSession.php
index 1abe5873c26ab63012b5b49ec61c9a71f1e49305..672648370e8d50131bf7665b9dfd1beadbcb199b 100644
--- a/src/TestUtils/TestUserSession.php
+++ b/src/TestUtils/TestUserSession.php
@@ -41,12 +41,12 @@ class TestUserSession implements UserSessionInterface
         return $this->roles;
     }
 
-    public function getSessionLoggingId(): ?string
+    public function getSessionLoggingId(): string
     {
         return 'logging-id';
     }
 
-    public function getSessionCacheKey(): ?string
+    public function getSessionCacheKey(): string
     {
         return 'cache';
     }
diff --git a/tests/Auth/AuthenticatorTest.php b/tests/Auth/AuthenticatorTest.php
index 26d9879b226455b678f50a1476312c9711976705..99dc9f9f491c58ed7953da392b08a20ac0d63992 100644
--- a/tests/Auth/AuthenticatorTest.php
+++ b/tests/Auth/AuthenticatorTest.php
@@ -6,6 +6,7 @@ namespace Dbp\Relay\CoreBundle\Tests\Auth;
 
 use Dbp\Relay\CoreBundle\Auth\AuthenticatorCompilerPass;
 use Dbp\Relay\CoreBundle\Auth\ProxyAuthenticator;
+use Dbp\Relay\CoreBundle\Auth\UserSession;
 use Dbp\Relay\CoreBundle\TestUtils\TestAuthenticator;
 use Dbp\Relay\CoreBundle\TestUtils\TestUser;
 use PHPUnit\Framework\TestCase;
@@ -17,13 +18,13 @@ class AuthenticatorTest extends TestCase
 {
     public function testSupports()
     {
-        $auth = new ProxyAuthenticator();
+        $auth = new ProxyAuthenticator(new UserSession());
         $this->assertFalse($auth->supports(new Request()));
     }
 
     public function testSingle()
     {
-        $auth = new ProxyAuthenticator();
+        $auth = new ProxyAuthenticator(new UserSession());
         $user = new TestUser();
         $sub = new TestAuthenticator($user, 'bla');
         $auth->addAuthenticator($sub);
diff --git a/tests/LocaleTest.php b/tests/LocaleTest.php
new file mode 100644
index 0000000000000000000000000000000000000000..f5c08826d403c4cc6f7cec48461ba7bc4a795e56
--- /dev/null
+++ b/tests/LocaleTest.php
@@ -0,0 +1,58 @@
+<?php
+
+declare(strict_types=1);
+
+namespace Dbp\Relay\CoreBundle\Tests;
+
+use Dbp\Relay\CoreBundle\Locale\Locale;
+use PHPUnit\Framework\TestCase;
+use Symfony\Component\DependencyInjection\ParameterBag\ParameterBag;
+use Symfony\Component\HttpFoundation\Request;
+use Symfony\Component\HttpFoundation\RequestStack;
+
+class LocaleTest extends TestCase
+{
+    public function testWithRequest()
+    {
+        $stack = new RequestStack();
+        $request = new Request(['lang' => 'de']);
+
+        $request->setLocale(\Locale::acceptFromHttp('en'));
+        $stack->push($request);
+        $params = new ParameterBag([]);
+        $service = new Locale($stack, $params);
+
+        $lang = $service->getCurrentPrimaryLanguage();
+        $this->assertSame('en', $lang);
+
+        $service->setCurrentRequestLocaleFromQuery('lang');
+        $lang = $service->getCurrentPrimaryLanguage();
+        $this->assertSame('de', $lang);
+    }
+
+    public function testWithoutRequest()
+    {
+        $stack = new RequestStack();
+        $params = new ParameterBag(['kernel.default_locale' => \Locale::acceptFromHttp('de')]);
+        $service = new Locale($stack, $params);
+
+        $lang = $service->getCurrentPrimaryLanguage();
+        $this->assertSame('de', $lang);
+    }
+
+    public function testSetExplicit()
+    {
+        $stack = new RequestStack();
+        $params = new ParameterBag(['kernel.default_locale' => \Locale::acceptFromHttp('en')]);
+        $service = new Locale($stack, $params);
+        $request = new Request(['foo' => 'fr']);
+        $service->setRequestLocaleFromQuery($request, 'foo');
+
+        $stack->push($request);
+        $lang = $service->getCurrentPrimaryLanguage();
+        $this->assertSame('fr', $lang);
+        $stack->pop();
+        $lang = $service->getCurrentPrimaryLanguage();
+        $this->assertSame('en', $lang);
+    }
+}