Repository: hpi-schul-cloud/schulcloud-client
Pull request #3627: BC-7210 - Change the Session Store to a dedicated valkey cluster
Author: login: SevenWaysDP
Created: 2025-04-28T15:15:33Z | Merged: 2025-05-06T14:25:55Z

# Description
<!--
  This is a template to add as much information as possible to the pull request, to help reviewer and as a checklist for you. Points to remember are set in the comments, please read and keep them in mind:

    - Code should be self-explanatory and share your knowledge with others
    - Document code that is not self-explanatory
    - Think about bugs and keep security in mind
    - Write tests (Unit and end-to-end-tests), also for error cases
    - Business logic should be implemented in the API; never trust the client
    - Visible changes should be discussed with the UX-Team from the beginning of development; they also have to accept them at the end
    - Keep the changelog up-to-date
    - Leave the code cleaner than you found it. Remove unnecessary lines. Listen to the linter.
-->

## Links to Tickets or other pull requests

- https://ticketsystem.dbildungscloud.de/browse/BC-7210

- https://github.com/hpi-schul-cloud/dof_app_deploy/pull/1156
- https://github.com/hpi-schul-cloud/schulcloud-server/pull/5652

## Changes
<!--
  What will the PR change?
  Why are the changes required?
  Short notice if a ticket exists, more detailed if not
-->

## Data Security
<!--
  Notice about:
  - model changes
  - logging of user data
  - right changes
  - and other user data stuff
  If you are not sure if it is relevant, take a look at confluence or ask the data security team.
-->

## Deployment
<!--
  Keep in mind to changes to seed data, if changes are done by migration scripts.
  Changes to the infrastructure have to discussed with the devops

  This point should include following information:
  - What is required for deployment?
  - Environment variables like FEATURE_XY=true
  - Migration scripts to run, other requirements
-->

## New Repos, NPM packages or vendor scripts
<!--
  Keep in mind the stability, performance, activity and author.

  Describe why it is needed.
-->

## Screenshots of UI changes
<!--
  only needed for visual changes

  If visual changes exist, work together with UI/UX from beginning/ping UX with final PR
-->

## Approval for review

- [ ] QA: In addition to review, the code has been manually tested (if manual testing is possible)
- [ ] All points were discussed with the ticket creator, support-team or product owner. The code upholds all quality guidelines from the PR-template.
- [ ] DEV: Every new component is implemented having accessibility in mind (e.g. aria-label, role property)

> Notice: Please remove the WIP label if the PR is ready to review, otherwise nobody will review it.



Diff:
diff --git a/ansible/roles/schulcloud-client-core/templates/deployment.yml.j2 b/ansible/roles/schulcloud-client-core/templates/deployment.yml.j2
index 73b4f2ddce..17032cb826 100644
--- a/ansible/roles/schulcloud-client-core/templates/deployment.yml.j2
+++ b/ansible/roles/schulcloud-client-core/templates/deployment.yml.j2
@@ -63,6 +63,8 @@ spec:
             name: client-configmap
         - secretRef:
             name: client-secret
+        - secretRef:
+            name: session-valkey-password
         volumeMounts:
         - mountPath: /home/node/app/config/http-headers.js
           subPath: http-headers.js
diff --git a/ansible/roles/schulcloud-client-core/templates/secret.yml.j2 b/ansible/roles/schulcloud-client-core/templates/secret.yml.j2
index 906c0facf9..49058af542 100644
--- a/ansible/roles/schulcloud-client-core/templates/secret.yml.j2
+++ b/ansible/roles/schulcloud-client-core/templates/secret.yml.j2
@@ -8,7 +8,6 @@ metadata:
   namespace: {{ NAMESPACE }}
 type: Opaque
 data:
-  REDIS_URI: "{{ REDIS_URI|default("redis://redis-svc", true) | b64encode }}"
 {% if GHOST_API_KEY is defined and GHOST_API_KEY|length and GHOST_BASE_URL is defined and GHOST_BASE_URL|length %}
   GHOST_API_KEY: "{{ GHOST_API_KEY| b64encode  }}"
   GHOST_API_URL: "{{ (concat GHOST_BASE_URL '/ghost/api/v3') | b64encode  }}"
diff --git a/app.js b/app.js
index 4a24aa4b7d..f4105f5f8b 100644
--- a/app.js
+++ b/app.js
@@ -4,15 +4,13 @@ const morgan = require('morgan');
 const cookieParser = require('cookie-parser');
 const bodyParser = require('body-parser');
 const compression = require('compression');
-const Redis = require('ioredis');
-const RedisStore = require('connect-redis').default;
-const session = require('express-session');
 const methodOverride = require('method-override');
 const csurf = require('csurf');
 const handlebars = require('handlebars');
 const layouts = require('handlebars-layouts');
 const handlebarsWax = require('handlebars-wax');
 const { Configuration } = require('@hpi-schul-cloud/commons');
+const { initializeSessionStorage } = require('./helpers/sessionStorage');
 
 const { staticAssetsMiddleware } = require('./middleware/assets');
 const { version } = require('./package.json');
@@ -31,281 +29,268 @@ const {
 	KEEP_ALIVE,
 	SC_DOMAIN,
 	SC_THEME,
-	REDIS_URI,
 	JWT_SHOW_TIMEOUT_WARNING_SECONDS,
 	MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE,
 	JWT_TIMEOUT_SECONDS,
 	API_HOST,
 	PUBLIC_BACKEND_URL,
 } = require('./config/global');
-
-const app = express();
-
-// print current configuration
-Configuration.printHierarchy();
-
-// setup prometheus metrics
-prometheus(app);
-
-// template stuff
 const authHelper = require('./helpers/authentication');
-
-// set custom response header for ha proxy
-if (KEEP_ALIVE) {
-	app.use((req, res, next) => {
-		res.setHeader('Connection', 'Keep-Alive');
-		next();
-	});
-}
-
-// disable x-powered-by header
-app.disable('x-powered-by');
-
-// set security headers
 const securityHeaders = require('./middleware/security_headers');
-
-app.use(securityHeaders);
-
-// generate nonce value
-if (Configuration.get('CORS')) {
-	app.use(nonceValueSet);
-}
-
-// set cors headers
-app.use(require('./middleware/cors'));
-
-app.use(compression());
-app.set('trust proxy', true);
-const themeName = SC_THEME;
-// view engine setup
+const cors = require('./middleware/cors');
 const handlebarsHelper = require('./helpers/handlebars');
+const setTheme = require('./helpers/theme');
+const controllers = require('./controllers');
+const i18nMiddleware = require('./middleware/i18n');
+const datetimeMiddleware = require('./middleware/datetime');
 
-const wax = handlebarsWax(handlebars)
-	.partials(path.join(__dirname, 'views/**/*.{hbs,js}'))
-	.helpers(layouts)
-	.helpers(handlebarsHelper.helpers(app));
+const setupApp = async () => {
+	const app = express();
 
-wax.partials(path.join(__dirname, `theme/${themeName}/views/**/*.{hbs,js}`));
+	// print current configuration
+	Configuration.printHierarchy();
 
-const viewDirs = [path.join(__dirname, 'views')];
-viewDirs.unshift(path.join(__dirname, `theme/${themeName}/views/`));
+	// setup prometheus metrics
+	prometheus(app);
 
-app.set('views', viewDirs);
-app.engine('hbs', wax.engine);
-app.set('view engine', 'hbs');
+	// set custom response header for ha proxy
+	if (KEEP_ALIVE) {
+		app.use((req, res, next) => {
+			res.setHeader('Connection', 'Keep-Alive');
+			next();
+		});
+	}
 
-app.set('view cache', true);
+	// disable x-powered-by header
+	app.disable('x-powered-by');
 
-// uncomment after placing your favicon in /public
-// app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
-if (Configuration.get('FEATURE_MORGAN_LOG_ENABLED')) {
-	let morganLogFormat = Configuration.get('MORGAN_LOG_FORMAT');
-	const noColor = Configuration.has('NO_COLOR') && Configuration.get('NO_COLOR');
+	// set security headers
+	app.use(securityHeaders);
 
-	if (morganLogFormat === 'dev' && noColor) {
-		morganLogFormat = ':method :url :status :response-time ms - :res[content-length]';
+	// generate nonce value
+	if (Configuration.get('CORS')) {
+		app.use(nonceValueSet);
 	}
 
-	app.use(morgan(morganLogFormat));
-}
+	// set cors headers
+	app.use(cors);
 
-app.use(bodyParser.json({ limit: '10mb' }));
-app.use(bodyParser.urlencoded({ extended: true, limit: '10mb' }));
-app.use(cookieParser());
+	app.use(compression());
+	app.set('trust proxy', true);
+	const themeName = SC_THEME;
 
-staticAssetsMiddleware(app);
+	const wax = handlebarsWax(handlebars)
+		.partials(path.join(__dirname, 'views/**/*.{hbs,js}'))
+		.helpers(layouts)
+		.helpers(handlebarsHelper.helpers(app));
 
-let sessionStore;
-const redisUrl = REDIS_URI;
-if (redisUrl) {
-	logger.info(`Using Redis session store at '${redisUrl}'.`);
-	const client = new Redis(redisUrl);
+	wax.partials(path.join(__dirname, `theme/${themeName}/views/**/*.{hbs,js}`));
 
-	// The error event must be handled, otherwise the app crashes on redis connection errors.
-	// This is due to basic NodeJS behavior: https://nodejs.org/api/events.html#error-events
-	client.on('error', (err) => {
-		logger.error('Redis client error', err);
-	});
+	const viewDirs = [path.join(__dirname, 'views')];
+	viewDirs.unshift(path.join(__dirname, `theme/${themeName}/views/`));
 
-	sessionStore = new RedisStore({ client });
-} else {
-	logger.info('Using in-memory session store.');
-	sessionStore = new session.MemoryStore();
-}
-
-const SIX_HOURS = 1000 * 60 * 60 * 6;
-app.use(session({
-	cookie: {
-		httpOnly: true,
-		sameSite: Configuration.get('SESSION_COOKIE_SAME_SITE'),
-		secure: 'auto',
-		maxAge: SIX_HOURS,
-	},
-	rolling: true, // refresh session with every request within maxAge
-	store: sessionStore,
-	saveUninitialized: true,
-	resave: false,
-	secret: Configuration.get('COOKIE_SECRET'), // Secret used to sign the session ID cookie
-}));
-
-// CSRF middlewares
-if (Configuration.get('FEATURE_CSRF_ENABLED')) {
-	app.use(duplicateTokenHandler);
-	app.use(csurf());
-	app.use(tokenInjector);
-	// there follows an csrf error handler below...
-}
+	app.set('views', viewDirs);
+	app.engine('hbs', wax.engine);
+	app.set('view engine', 'hbs');
 
-const setTheme = require('./helpers/theme');
+	app.set('view cache', true);
 
-function removeIds(url) {
-	const checkForHexRegExp = /[a-f\d]{24}/ig;
-	return url.replace(checkForHexRegExp, 'ID');
-}
-
-// Custom flash middleware
-app.use(async (req, res, next) => {
-	// if there's a flash message in the session request, make it available in the response, then delete it
-	res.locals.notification = req.session.notification;
-	res.locals.inline = req.query.inline || false;
-	setTheme(res);
-	res.locals.domain = SC_DOMAIN;
-	res.locals.production = req.app.get('env') === 'production';
-	res.locals.env = req.app.get('env') || false; // TODO: ist das false hier nicht quatsch?
-	res.locals.JWT_SHOW_TIMEOUT_WARNING_SECONDS = Number(JWT_SHOW_TIMEOUT_WARNING_SECONDS);
-	res.locals.MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE = Number(MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE);
-	// eslint-disable-next-line max-len
-	res.locals.MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_MEGABYTE = (MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE / 1024 / 1024);
-	res.locals.JWT_TIMEOUT_SECONDS = Number(JWT_TIMEOUT_SECONDS);
-	res.locals.API_HOST = PUBLIC_BACKEND_URL || `${API_HOST}/`;
-	res.locals.version = version;
-	res.locals.sha = sha;
-	res.locals.ROCKETCHAT_SERVICE_ENABLED = Configuration.get('ROCKETCHAT_SERVICE_ENABLED');
-	delete req.session.notification;
-	return next();
-});
-
-app.use(methodOverride('_method')); // for GET requests
-app.use(methodOverride((req, res, next) => { // for POST requests
-	if (req.body && typeof req.body === 'object' && '_method' in req.body) {
-		// eslint-disable-next-line no-underscore-dangle
-		const method = req.body._method;
-		// eslint-disable-next-line no-underscore-dangle
-		delete req.body._method;
-		return method;
-	}
-	return undefined;
-}));
+	// uncomment after placing your favicon in /public
+	// app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
+	if (Configuration.get('FEATURE_MORGAN_LOG_ENABLED')) {
+		let morganLogFormat = Configuration.get('MORGAN_LOG_FORMAT');
+		const noColor = Configuration.has('NO_COLOR') && Configuration.get('NO_COLOR');
 
-// add res.$t method for i18n with users prefered language
-app.use(require('./middleware/i18n'));
-app.use(require('./middleware/datetime'));
+		if (morganLogFormat === 'dev' && noColor) {
+			morganLogFormat = ':method :url :status :response-time ms - :res[content-length]';
+		}
 
-const redirectUrl = Configuration.get('ROOT_URL_REDIRECT');
-if (redirectUrl !== '') {
-	app.get('/', (req, res, next) => {
-		res.redirect(redirectUrl);
-	});
-}
-
-// Initialize the modules and their routes
-app.use(require('./controllers'));
-
-// catch 404 and forward to error handler
-app.use((req, res, next) => {
-	const url = req.originalUrl || req.url;
-	const err = new Error(`Page Not Found ${url}`);
-	err.status = 404;
-	next(err);
-});
-
-// error handlers
-if (Configuration.get('FEATURE_CSRF_ENABLED')) {
-	app.use(csrfErrorHandler);
-}
-
-// no statusCode exist for this cases
-const isTimeoutError = (err) => err && err.message && (
-	err.message.includes('ESOCKETTIMEDOUT')
-	|| err.message.includes('ECONNREFUSED')
-	|| err.message.includes('ETIMEDOUT')
-);
-
-const errorHandler = (err) => {
-	const error = err.error || err;
-	const status = error.status || error.statusCode || 500;
-	error.statusCode = status;
-
-	// prevent logging jwts and x-api-keys
-	if (error.options && error.options.headers) {
-		delete error.options.headers;
+		app.use(morgan(morganLogFormat));
 	}
 
-	return { error, status };
-};
+	app.use(bodyParser.json({ limit: '10mb' }));
+	app.use(bodyParser.urlencoded({ extended: true, limit: '10mb' }));
+	app.use(cookieParser());
 
-app.use((err, req, res, next) => {
-	const { error, status } = errorHandler(err);
+	staticAssetsMiddleware(app);
 
-	if (!res.locals) {
-		res.locals = {};
-	}
+	await initializeSessionStorage(app);
 
-	if (Configuration.get('FEATURE_LOG_REQUEST') === true) {
-		const reqInfo = {
-			url: req.originalUrl || req.url,
-			method: req.originalMethod || req.method,
-			params: req.params,
-			body: req.body,
-		};
-		error.requestInfo = filterLog(reqInfo);
+	// CSRF middlewares
+	if (Configuration.get('FEATURE_CSRF_ENABLED')) {
+		app.use(duplicateTokenHandler);
+		app.use(csurf());
+		app.use(tokenInjector);
+		// there follows an csrf error handler below...
 	}
 
-	if (res.locals.currentUser) {
-		res.locals.loggedin = true;
-		const { _id, schoolId, roles } = res.locals.currentUser;
-		error.currentUser = {
-			userId: _id,
-			schoolId,
-			roles: (roles || []).map((r) => r.name),
-		};
-	}
+	// Custom flash middleware
+	app.use(async (req, res, next) => {
+		// if there's a flash message in the session request, make it available in the response, then delete it
+		res.locals.notification = req.session.notification;
+		res.locals.inline = req.query.inline || false;
+		setTheme(res);
+		res.locals.domain = SC_DOMAIN;
+		res.locals.production = req.app.get('env') === 'production';
+		res.locals.env = req.app.get('env') || false; // TODO: ist das false hier nicht quatsch?
+		res.locals.JWT_SHOW_TIMEOUT_WARNING_SECONDS = Number(
+			JWT_SHOW_TIMEOUT_WARNING_SECONDS,
+		);
+		res.locals.MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE = Number(
+			MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE,
+		);
+		// eslint-disable-next-line max-len
+		res.locals.MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_MEGABYTE = MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE / 1024 / 1024;
+		res.locals.JWT_TIMEOUT_SECONDS = Number(JWT_TIMEOUT_SECONDS);
+		res.locals.API_HOST = PUBLIC_BACKEND_URL || `${API_HOST}/`;
+		res.locals.version = version;
+		res.locals.sha = sha;
+		res.locals.ROCKETCHAT_SERVICE_ENABLED = Configuration.get(
+			'ROCKETCHAT_SERVICE_ENABLED',
+		);
+		delete req.session.notification;
+		return next();
+	});
 
-	if (error.message) {
-		res.setHeader('error-message', error.message);
-		res.locals.message = error.message;
-	} else {
-		res.locals.message = `Error with statusCode ${status}`;
+	app.use(methodOverride('_method')); // for GET requests
+	app.use(
+		methodOverride((req, res, next) => {
+			// for POST requests
+			if (
+				req.body
+				&& typeof req.body === 'object'
+				&& '_method' in req.body
+			) {
+				// eslint-disable-next-line no-underscore-dangle
+				const method = req.body._method;
+				// eslint-disable-next-line no-underscore-dangle
+				delete req.body._method;
+				return method;
+			}
+			return undefined;
+		}),
+	);
+
+	// add res.$t method for i18n with users prefered language
+	app.use(i18nMiddleware);
+	app.use(datetimeMiddleware);
+
+	const redirectUrl = Configuration.get('ROOT_URL_REDIRECT');
+	if (redirectUrl !== '') {
+		app.get('/', (req, res, next) => {
+			res.redirect(redirectUrl);
+		});
 	}
 
-	// override with try again message by timeouts
-	if (isTimeoutError(error)) {
-		const baseRoute = typeof err.options.baseUrl === 'string' ? err.options.baseUrl.slice(0, -1) : '';
-		const route = baseRoute + err.options.uri;
-		const routeMessage = res.locals.production ? '' : ` beim Aufruf der Route ${route}`;
-		res.locals.message = `Es ist ein Fehler aufgetreten${routeMessage}. Bitte versuche es erneut.`;
-	}
+	// Initialize the modules and their routes
+	app.use(controllers);
 
-	// do not show full errors in production mode
-	res.locals.error = req.app.get('env') === 'development' ? err : { status };
+	// catch 404 and forward to error handler
+	app.use((req, res, next) => {
+		const url = req.originalUrl || req.url;
+		const err = new Error(`Page Not Found ${url}`);
+		err.status = 404;
+		next(err);
+	});
 
-	logger.error(error);
+	// error handlers
+	if (Configuration.get('FEATURE_CSRF_ENABLED')) {
+		app.use(csrfErrorHandler);
+	}
 
-	// keep sidebar restricted in error page
-	authHelper.restrictSidebar(req, res);
+	// no statusCode exist for this cases
+	const isTimeoutError = (err) => err
+		&& err.message
+		&& (err.message.includes('ESOCKETTIMEDOUT')
+			|| err.message.includes('ECONNREFUSED')
+			|| err.message.includes('ETIMEDOUT'));
+
+	const errorHandler = (err) => {
+		const error = err.error || err;
+		const status = error.status || error.statusCode || 500;
+		error.statusCode = status;
+
+		// prevent logging jwts and x-api-keys
+		if (error.options && error.options.headers) {
+			delete error.options.headers;
+		}
+
+		return { error, status };
+	};
+
+	app.use((err, req, res, next) => {
+		const { error, status } = errorHandler(err);
+
+		if (!res.locals) {
+			res.locals = {};
+		}
+
+		if (Configuration.get('FEATURE_LOG_REQUEST') === true) {
+			const reqInfo = {
+				url: req.originalUrl || req.url,
+				method: req.originalMethod || req.method,
+				params: req.params,
+				body: req.body,
+			};
+			error.requestInfo = filterLog(reqInfo);
+		}
+
+		if (res.locals.currentUser) {
+			res.locals.loggedin = true;
+			const { _id, schoolId, roles } = res.locals.currentUser;
+			error.currentUser = {
+				userId: _id,
+				schoolId,
+				roles: (roles || []).map((r) => r.name),
+			};
+		}
+
+		if (error.message) {
+			res.setHeader('error-message', error.message);
+			res.locals.message = error.message;
+		} else {
+			res.locals.message = `Error with statusCode ${status}`;
+		}
+
+		// override with try again message by timeouts
+		if (isTimeoutError(error)) {
+			const baseRoute = typeof err.options.baseUrl === 'string'
+				? err.options.baseUrl.slice(0, -1)
+				: '';
+			const route = baseRoute + err.options.uri;
+			const routeMessage = res.locals.production
+				? ''
+				: ` beim Aufruf der Route ${route}`;
+			res.locals.message = `Es ist ein Fehler aufgetreten${routeMessage}. Bitte versuche es erneut.`;
+		}
+
+		// do not show full errors in production mode
+		res.locals.error = req.app.get('env') !== 'development' ? err : { status };
+
+		logger.error(error);
+
+		// keep sidebar restricted in error page
+		authHelper.restrictSidebar(req, res);
+
+		// render the error page
+		res.status(status).render('lib/error', {
+			pageTitle: res.$t('lib.error.headline.pageTitle'),
+			loggedin: res.locals.loggedin,
+			inline: res.locals.inline ? true : !res.locals.loggedin,
+		});
+	});
 
-	// render the error page
-	res.status(status).render('lib/error', {
-		pageTitle: res.$t('lib.error.headline.pageTitle'),
-		loggedin: res.locals.loggedin,
-		inline: res.locals.inline ? true : !res.locals.loggedin,
+	process.on('unhandledRejection', (err) => {
+		const { error } = errorHandler(err);
+		error.message = `unhandledRejection: ${error.message}`;
+		logger.error(error);
 	});
-});
 
-process.on('unhandledRejection', (err) => {
-	const { error } = errorHandler(err);
-	error.message = `unhandledRejection: ${error.message}`;
-	logger.error(error);
-});
+	return app;
+};
+
+module.exports = async () => {
+	const app = await setupApp();
 
-module.exports = app;
+	return app;
+};
diff --git a/bin/www b/bin/www
index b6c035650c..852a15e5bc 100755
--- a/bin/www
+++ b/bin/www
@@ -5,37 +5,6 @@
  */
 
 const http = require('http');
-const app = require('../app');
-
-/**
- * Get port from environment and store in Express.
- */
-
-const port = normalizePort(process.env.PORT || '3100');
-app.set('port', port);
-
-/**
- * Create HTTP server.
- */
-
-const server = http.createServer(app);
-
-/**
- * Listen on provided port, on all network interfaces.
- */
-
-server.listen(port);
-server.on('error', onError);
-server.on('listening', onListening);
-
-/**
- * Gracefully stops server on SIGINT. Makes it possible to run tasks before exit.
- */
-process.on('SIGINT', () => {
-	server.close();
-	process.exit();
-});
-
 
 /**
  * Normalize a port into a number, string, or false.
@@ -44,7 +13,7 @@ process.on('SIGINT', () => {
 function normalizePort(val) {
 	const port = parseInt(val, 10);
 
-	if (isNaN(port)) {
+	if (Number.isNaN(port)) {
 		// named pipe
 		return val;
 	}
@@ -57,43 +26,71 @@ function normalizePort(val) {
 	return false;
 }
 
-/**
- * Event listener for HTTP server "error" event.
- */
+const appInit = require('../app');
 
-function onError(error) {
-	if (error.syscall !== 'listen') {
-		throw error;
-	}
+appInit().then((app) => {
+	/**
+	 * Get port from environment and store in Express.
+	 */
+
+	const port = normalizePort(process.env.PORT || '3100');
+	app.set('port', port);
+
+	/**
+	 * Create HTTP server.
+	 */
+
+	const server = http.createServer(app);
+	/**
+	 * Gracefully stops server on SIGINT. Makes it possible to run tasks before exit.
+	 */
+	process.on('SIGINT', () => {
+		server.close();
+		process.exit();
+	});
+
+	/**
+	 * Event listener for HTTP server "error" event.
+	 */
 
-	const bind = typeof port === 'string'
-		? `Pipe ${port}`
-		: `Port ${port}`;
-
-	// handle specific listen errors with friendly messages
-	switch (error.code) {
-		case 'EACCES':
-			console.error(`${bind} requires elevated privileges`);
-			process.exit(1);
-			break;
-		case 'EADDRINUSE':
-			console.error(`${bind} is already in use`);
-			process.exit(1);
-			break;
-		default:
+	function onError(error) {
+		if (error.syscall !== 'listen') {
 			throw error;
+		}
+
+		const bind = typeof port === 'string' ? `Pipe ${port}` : `Port ${port}`;
+
+		// handle specific listen errors with friendly messages
+		switch (error.code) {
+			case 'EACCES':
+				console.error(`${bind} requires elevated privileges`);
+				process.exit(1);
+				break;
+			case 'EADDRINUSE':
+				console.error(`${bind} is already in use`);
+				process.exit(1);
+				break;
+			default:
+				throw error;
+		}
 	}
-}
 
-/**
- * Event listener for HTTP server "listening" event.
- */
+	/**
+	 * Event listener for HTTP server "listening" event.
+	 */
 
-function onListening() {
-	const addr = server.address();
-	const bind = typeof addr === 'string'
-		? `pipe ${addr}`
-		: `port ${addr.port}`;
+	function onListening() {
+		const addr = server.address();
+		const bind = typeof addr === 'string' ? `pipe ${addr}` : `port ${addr.port}`;
 
-	console.log(`Listening on ${bind}`);
-}
+		console.log(`Listening on ${bind}`);
+	}
+
+	/**
+	 * Listen on provided port, on all network interfaces.
+	 */
+
+	server.listen(port);
+	server.on('error', onError);
+	server.on('listening', onListening);
+});
diff --git a/config/default.schema.json b/config/default.schema.json
index 49f3fd4629..218ce02168 100644
--- a/config/default.schema.json
+++ b/config/default.schema.json
@@ -122,6 +122,35 @@
 			],
 			"description": "Sets the winston log level. Winston is used for all logs. @see AbstractConfigSetLevels"
 		},
+		"SESSION_VALKEY": {
+			"type": "object",
+			"description": "Session configuration.",
+			"properties": {
+				"MODE": {
+					"type": "string",
+					"default": "cluster",
+					"enum": ["cluster", "single" ,"in-memory"],
+					"description": "Define valkey mode."
+				},
+				"URI": {
+					"type": "string",
+					"pattern": "(rediss?:)?//((\\w+)?(:.+@)?)?((\\w+)?(:\\w+))?(/d+)?(\\?db=\\d+(&password=.+(&option=.+)*)?)?",
+					"description": "URL of a Valkey server. If undefined, JWT whitelisting and timeouts will be disabled. Format: [redis[s]:]//[[user][:password@]][host][:port][/db-number][?db=db-number[&password=bar[&option=value]]]"
+				},
+				"SENTINEL_NAME": {
+					"type": "string",
+					"description": "The name of the Redis Sentinel."
+				},
+				"SENTINEL_PASSWORD": {
+					"type": "string",
+					"description": "The password for the Redis Sentinel service."
+				},
+				"SENTINEL_SERVICE_NAME": {
+					"type": "string",
+					"description": "The name of the Redis Sentinel service."
+				}
+			}
+		},
 		"TEACHER_VISIBILITY_FOR_EXTERNAL_TEAM_INVITATION": {
 			"type": "string",
 			"default": "disabled",
diff --git a/config/development.json b/config/development.json
index bcb67ea313..abfb400603 100644
--- a/config/development.json
+++ b/config/development.json
@@ -11,5 +11,8 @@
 	"FEATURE_PROMETHEUS_ENABLED": true,
 	"FEATURE_ALERTS_ENABLED": true,
 	"PUBLIC_BACKEND_URL": "http://localhost:3030/api",
-	"FEATURE_ROOMS_ENABLED": true
+	"FEATURE_ROOMS_ENABLED": true,
+	"SESSION_VALKEY": {
+		"MODE": "in-memory"
+	}
 }
diff --git a/config/global.js b/config/global.js
index 9ac9e93924..7124f737c6 100644
--- a/config/global.js
+++ b/config/global.js
@@ -7,7 +7,6 @@ const {
 	SC_TITLE = 'dBildungscloud',
 	DOCUMENT_BASE_DIR = 'https://s3.hidrive.strato.com/cloud-instances/',
 	CONSENT_WITHOUT_PARENTS_MIN_AGE_YEARS,
-	REDIS_URI,
 	NODE_ENV = 'development',
 	JWT_SHOW_TIMEOUT_WARNING_SECONDS = 3600, // 60 min
 	JWT_TIMEOUT_SECONDS,
@@ -44,7 +43,6 @@ const exp = {
 	SC_TITLE,
 	DOCUMENT_BASE_DIR,
 	CONSENT_WITHOUT_PARENTS_MIN_AGE_YEARS: parseInt(CONSENT_WITHOUT_PARENTS_MIN_AGE_YEARS || 16, 10),
-	REDIS_URI,
 	NODE_ENV,
 	JWT_SHOW_TIMEOUT_WARNING_SECONDS,
 	JWT_TIMEOUT_SECONDS,
diff --git a/config/test.json b/config/test.json
index 0083dbbe0b..4f33e5e6ba 100644
--- a/config/test.json
+++ b/config/test.json
@@ -7,5 +7,8 @@
 		"SAME_SITE": "none",
 		"HOST_ONLY": true
 	},
-	"PUBLIC_BACKEND_URL": "http://localhost:3030/api"
+	"PUBLIC_BACKEND_URL": "http://localhost:3030/api",
+	"SESSION_VALKEY": {
+		"MODE": "in-memory"
+	}
 }
diff --git a/helpers/sessionStorage.js b/helpers/sessionStorage.js
new file mode 100644
index 0000000000..6b6e555382
--- /dev/null
+++ b/helpers/sessionStorage.js
@@ -0,0 +1,32 @@
+const session = require('express-session');
+const { Configuration } = require('@hpi-schul-cloud/commons');
+const { ValkeyFactory } = require('./valkyFactory');
+
+function initSessionMiddleware(app, store) {
+	const SIX_HOURS = 1000 * 60 * 60 * 6;
+	app.use(
+		session({
+			cookie: {
+				httpOnly: true,
+				sameSite: Configuration.get('SESSION_COOKIE_SAME_SITE'),
+				secure: 'auto',
+				maxAge: SIX_HOURS,
+			},
+			rolling: true, // refresh session with every request within maxAge
+			store,
+			saveUninitialized: true,
+			resave: false,
+			secret: Configuration.get('COOKIE_SECRET'), // Secret used to sign the session ID cookie
+		}),
+	);
+}
+
+const initializeSessionStorage = async (app) => {
+	const sessionStore = await ValkeyFactory.build();
+
+	initSessionMiddleware(app, sessionStore);
+};
+
+module.exports = {
+	initializeSessionStorage,
+};
diff --git a/helpers/valkyFactory.js b/helpers/valkyFactory.js
new file mode 100644
index 0000000000..0c0917b5b6
--- /dev/null
+++ b/helpers/valkyFactory.js
@@ -0,0 +1,95 @@
+const { Configuration } = require('@hpi-schul-cloud/commons');
+const Valkey = require('iovalkey');
+const RedisStore = require('connect-redis').default;
+const util = require('util');
+const session = require('express-session');
+const dns = require('dns');
+const logger = require('./logger');
+
+class ValkeyFactory {
+	static async build() {
+		let storageClient;
+		const mode = Configuration.get('SESSION_VALKEY__MODE');
+
+		if (mode === 'cluster') {
+			storageClient = await ValkeyFactory.createValkeySentinelInstanceStore();
+		} else if (mode === 'single') {
+			storageClient = ValkeyFactory.createValkeyInstanceStore();
+		} else if (mode === 'in-memory') {
+			storageClient = ValkeyFactory.createInMemoryStorage();
+		} else {
+			throw new Error(`Undefined valkey mode ${mode}`);
+		}
+
+		return storageClient;
+	}
+
+	static createInMemoryStorage() {
+		const storageClient = new session.MemoryStore();
+		logger.info('Using in-memory session store.');
+
+		return storageClient;
+	}
+
+	static createValkeyInstance(params) {
+		const redisInstance = new Valkey(params);
+		redisInstance.on('error', (error) => logger.error(error));
+		redisInstance.on('connect', (msg) => logger.info(msg));
+
+		return redisInstance;
+	}
+
+	static createValkeyInstanceStore() {
+		const redisUri = Configuration.get('SESSION_VALKEY__URI');
+		try {
+			const client = ValkeyFactory.createValkeyInstance(redisUri);
+			logger.info(`Using Valkey session store at '${redisUri}'.`);
+
+			const sessionStore = new RedisStore({ client });
+
+			return sessionStore;
+		} catch (err) {
+			throw new Error('Can not create valky instance.', { cause: err });
+		}
+	}
+
+	static async createValkeySentinelInstanceStore() {
+		const sentinelName = Configuration.get('SESSION_VALKEY__SENTINEL_NAME');
+		const sentinelPassword = Configuration.get('SESSION_VALKEY__SENTINEL_PASSWORD');
+		const sentinalServiceName = Configuration.get('SESSION_VALKEY__SENTINEL_SERVICE_NAME');
+
+		try {
+			const sentinels = await ValkeyFactory.discoverSentinelHosts(sentinalServiceName);
+			logger.info(`Using discovered Valkey sentinels: ${JSON.stringify(sentinels)}`);
+
+			const client = ValkeyFactory.createValkeyInstance({
+				sentinels,
+				sentinelPassword,
+				password: sentinelPassword,
+				name: sentinelName,
+			});
+
+			const sessionStore = new RedisStore({ client });
+
+			return sessionStore;
+		} catch (err) {
+			throw new Error('Can not create valky "sentinal" instance.', { cause: err });
+		}
+	}
+
+	static async discoverSentinelHosts(sentinalServiceName) {
+		const resolveSrv = util.promisify(dns.resolveSrv);
+		const records = await resolveSrv(sentinalServiceName);
+
+		const hosts = records.map((record) => ({
+			host: record.name,
+			port: record.port,
+		}));
+
+		return hosts;
+	}
+}
+
+module.exports = {
+	ValkeyFactory,
+};
diff --git a/package-lock.json b/package-lock.json
index ed4c87b283..12f0631153 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -72,7 +72,7 @@
 				"i18next-fetch-backend": "^2.2.0",
 				"i18next-localstorage-backend": "^3.1.3",
 				"i18next-sync-fs-backend": "^1.1.1",
-				"ioredis": "^5.3.2",
+				"iovalkey": "^0.3.1",
 				"jquery": "^3.6.0",
 				"jquery-datetimepicker": "^2.5.20",
 				"jsonwebtoken": "^9.0.0",
@@ -2889,10 +2889,11 @@
 			"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
 			"dev": true
 		},
-		"node_modules/@ioredis/commands": {
-			"version": "1.2.0",
-			"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
-			"integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="
+		"node_modules/@iovalkey/commands": {
+			"version": "0.1.0",
+			"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+			"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg==",
+			"license": "MIT"
 		},
 		"node_modules/@istanbuljs/load-nyc-config": {
 			"version": "1.1.0",
@@ -5900,6 +5901,7 @@
 			"version": "1.1.2",
 			"resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
 			"integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
+			"license": "Apache-2.0",
 			"engines": {
 				"node": ">=0.10.0"
 			}
@@ -7268,6 +7270,7 @@
 			"version": "2.1.0",
 			"resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz",
 			"integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==",
+			"license": "Apache-2.0",
 			"engines": {
 				"node": ">=0.10"
 			}
@@ -12901,12 +12904,13 @@
 				"node": ">=0.10.0"
 			}
 		},
-		"node_modules/ioredis": {
-			"version": "5.3.2",
-			"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz",
-			"integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==",
+		"node_modules/iovalkey": {
+			"version": "0.3.1",
+			"resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.3.1.tgz",
+			"integrity": "sha512-pSmFj/ZDFLP8AzqIAMpNJArhHYNeyqIwfcUULwZv8g6y3eaUGrlnlT7QXLrJAp0yiGCAqe1hPA33x/h5opNP9w==",
+			"license": "MIT",
 			"dependencies": {
-				"@ioredis/commands": "^1.1.1",
+				"@iovalkey/commands": "^0.1.0",
 				"cluster-key-slot": "^1.1.0",
 				"debug": "^4.3.4",
 				"denque": "^2.1.0",
@@ -12917,19 +12921,16 @@
 				"standard-as-callback": "^2.1.0"
 			},
 			"engines": {
-				"node": ">=12.22.0"
-			},
-			"funding": {
-				"type": "opencollective",
-				"url": "https://opencollective.com/ioredis"
+				"node": ">=18.12.0"
 			}
 		},
-		"node_modules/ioredis/node_modules/debug": {
-			"version": "4.3.4",
-			"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
-			"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+		"node_modules/iovalkey/node_modules/debug": {
+			"version": "4.4.0",
+			"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
+			"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
+			"license": "MIT",
 			"dependencies": {
-				"ms": "2.1.2"
+				"ms": "^2.1.3"
 			},
 			"engines": {
 				"node": ">=6.0"
@@ -12940,10 +12941,11 @@
 				}
 			}
 		},
-		"node_modules/ioredis/node_modules/ms": {
-			"version": "2.1.2",
-			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-			"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+		"node_modules/iovalkey/node_modules/ms": {
+			"version": "2.1.3",
+			"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+			"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
+			"license": "MIT"
 		},
 		"node_modules/ip-regex": {
 			"version": "2.1.0",
@@ -14610,7 +14612,8 @@
 		"node_modules/lodash.defaults": {
 			"version": "4.2.0",
 			"resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
-			"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ=="
+			"integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
+			"license": "MIT"
 		},
 		"node_modules/lodash.flattendeep": {
 			"version": "4.4.0",
@@ -14621,7 +14624,8 @@
 		"node_modules/lodash.isarguments": {
 			"version": "3.1.0",
 			"resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
-			"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg=="
+			"integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
+			"license": "MIT"
 		},
 		"node_modules/lodash.isplainobject": {
 			"version": "4.0.6",
@@ -19604,6 +19608,7 @@
 			"version": "1.2.0",
 			"resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
 			"integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
+			"license": "MIT",
 			"engines": {
 				"node": ">=4"
 			}
@@ -19612,6 +19617,7 @@
 			"version": "3.0.0",
 			"resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
 			"integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
+			"license": "MIT",
 			"dependencies": {
 				"redis-errors": "^1.0.0"
 			},
@@ -21250,7 +21256,8 @@
 		"node_modules/standard-as-callback": {
 			"version": "2.1.0",
 			"resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
-			"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="
+			"integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
+			"license": "MIT"
 		},
 		"node_modules/static-extend": {
 			"version": "0.1.2",
@@ -26186,10 +26193,10 @@
 			"integrity": "sha512-ZnQMnLV4e7hDlUvw8H+U8ASL02SS2Gn6+9Ac3wGGLIe7+je2AeAOxPY+izIPJDfFDb7eDjev0Us8MO1iFRN8hA==",
 			"dev": true
 		},
-		"@ioredis/commands": {
-			"version": "1.2.0",
-			"resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
-			"integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg=="
+		"@iovalkey/commands": {
+			"version": "0.1.0",
+			"resolved": "https://registry.npmjs.org/@iovalkey/commands/-/commands-0.1.0.tgz",
+			"integrity": "sha512-/B9W4qKSSITDii5nkBCHyPkIkAi+ealUtr1oqBJsLxjSRLka4pxun2VvMNSmcwgAMxgXtQfl0qRv7TE+udPJzg=="
 		},
 		"@istanbuljs/load-nyc-config": {
 			"version": "1.1.0",
@@ -33832,12 +33839,12 @@
 			"resolved": "https://registry.npmjs.org/invert-kv/-/invert-kv-1.0.0.tgz",
 			"integrity": "sha1-EEqOSqym09jNFXqO+L+rLXo//bY="
 		},
-		"ioredis": {
-			"version": "5.3.2",
-			"resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.3.2.tgz",
-			"integrity": "sha512-1DKMMzlIHM02eBBVOFQ1+AolGjs6+xEcM4PDL7NqOS6szq7H9jSaEkIUH6/a5Hl241LzW6JLSiAbNvTQjUupUA==",
+		"iovalkey": {
+			"version": "0.3.1",
+			"resolved": "https://registry.npmjs.org/iovalkey/-/iovalkey-0.3.1.tgz",
+			"integrity": "sha512-pSmFj/ZDFLP8AzqIAMpNJArhHYNeyqIwfcUULwZv8g6y3eaUGrlnlT7QXLrJAp0yiGCAqe1hPA33x/h5opNP9w==",
 			"requires": {
-				"@ioredis/commands": "^1.1.1",
+				"@iovalkey/commands": "^0.1.0",
 				"cluster-key-slot": "^1.1.0",
 				"debug": "^4.3.4",
 				"denque": "^2.1.0",
@@ -33849,17 +33856,17 @@
 			},
 			"dependencies": {
 				"debug": {
-					"version": "4.3.4",
-					"resolved": "https://registry.npmjs.org/debug/-/debug-4.3.4.tgz",
-					"integrity": "sha512-PRWFHuSU3eDtQJPvnNY7Jcket1j0t5OuOsFzPPzsekD52Zl8qUfFIPEiswXqIvHWGVHOgX+7G/vCNNhehwxfkQ==",
+					"version": "4.4.0",
+					"resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz",
+					"integrity": "sha512-6WTZ/IxCY/T6BALoZHaE4ctp9xm+Z5kY/pzYaCHRFeyVhojxlrm+46y68HA6hr0TcwEssoxNiDEUJQjfPZ/RYA==",
 					"requires": {
-						"ms": "2.1.2"
+						"ms": "^2.1.3"
 					}
 				},
 				"ms": {
-					"version": "2.1.2",
-					"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
-					"integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w=="
+					"version": "2.1.3",
+					"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+					"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="
 				}
 			}
 		},
diff --git a/package.json b/package.json
index bb80f84fe4..43bf4fab7a 100644
--- a/package.json
+++ b/package.json
@@ -90,7 +90,7 @@
 		"i18next-fetch-backend": "^2.2.0",
 		"i18next-localstorage-backend": "^3.1.3",
 		"i18next-sync-fs-backend": "^1.1.1",
-		"ioredis": "^5.3.2",
+		"iovalkey": "^0.3.1",
 		"jquery": "^3.6.0",
 		"jquery-datetimepicker": "^2.5.20",
 		"jsonwebtoken": "^9.0.0",
diff --git a/sc-config.json b/sc-config.json
index 3505f9e387..feb6d99549 100644
--- a/sc-config.json
+++ b/sc-config.json
@@ -1,3 +1,3 @@
 {
-	"secretMatches": ["SECRET", "KEY", "SALT", "PASSWORD", "DSN", "REDIS_URI"]
+	"secretMatches": ["SECRET", "KEY", "SALT", "PASSWORD", "DSN", "SESSION_VALKEY_SENTINEL_PASSWORD"]
 }


Reviews:
user: login: CeEv
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T09:54:19Z
user: login: CeEv
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-05T10:01:51Z
user: login: CeEv
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T06:48:13Z
user: login: CeEv
state: APPROVED
author_association: CONTRIBUTOR
submitted_at: 2025-05-06T06:48:58Z

Review comments:
diff_hunk: @@ -31,281 +29,268 @@ const {
 	KEEP_ALIVE,
 	SC_DOMAIN,
 	SC_THEME,
-	REDIS_URI,
 	JWT_SHOW_TIMEOUT_WARNING_SECONDS,
 	MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE,
 	JWT_TIMEOUT_SECONDS,
 	API_HOST,
 	PUBLIC_BACKEND_URL,
 } = require('./config/global');
-
-const app = express();
-
-// print current configuration
-Configuration.printHierarchy();
-
-// setup prometheus metrics
-prometheus(app);
-
-// template stuff
 const authHelper = require('./helpers/authentication');
-
-// set custom response header for ha proxy
-if (KEEP_ALIVE) {
-	app.use((req, res, next) => {
-		res.setHeader('Connection', 'Keep-Alive');
-		next();
-	});
-}
-
-// disable x-powered-by header
-app.disable('x-powered-by');
-
-// set security headers
 const securityHeaders = require('./middleware/security_headers');
-
-app.use(securityHeaders);
-
-// generate nonce value
-if (Configuration.get('CORS')) {
-	app.use(nonceValueSet);
-}
-
-// set cors headers
-app.use(require('./middleware/cors'));
-
-app.use(compression());
-app.set('trust proxy', true);
-const themeName = SC_THEME;
-// view engine setup
+const cors = require('./middleware/cors');
 const handlebarsHelper = require('./helpers/handlebars');
+const setTheme = require('./helpers/theme');
+const controllers = require('./controllers');
+const i18nMiddleware = require('./middleware/i18n');
+const datetimeMiddleware = require('./middleware/datetime');
 
-const wax = handlebarsWax(handlebars)
-	.partials(path.join(__dirname, 'views/**/*.{hbs,js}'))
-	.helpers(layouts)
-	.helpers(handlebarsHelper.helpers(app));
+const setupApp = async () => {
+	const app = express();
 
-wax.partials(path.join(__dirname, `theme/${themeName}/views/**/*.{hbs,js}`));
+	// print current configuration
+	Configuration.printHierarchy();
 
-const viewDirs = [path.join(__dirname, 'views')];
-viewDirs.unshift(path.join(__dirname, `theme/${themeName}/views/`));
+	// setup prometheus metrics
+	prometheus(app);
 
-app.set('views', viewDirs);
-app.engine('hbs', wax.engine);
-app.set('view engine', 'hbs');
+	// set custom response header for ha proxy
+	if (KEEP_ALIVE) {
+		app.use((req, res, next) => {
+			res.setHeader('Connection', 'Keep-Alive');
+			next();
+		});
+	}
 
-app.set('view cache', true);
+	// disable x-powered-by header
+	app.disable('x-powered-by');
 
-// uncomment after placing your favicon in /public
-// app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
-if (Configuration.get('FEATURE_MORGAN_LOG_ENABLED')) {
-	let morganLogFormat = Configuration.get('MORGAN_LOG_FORMAT');
-	const noColor = Configuration.has('NO_COLOR') && Configuration.get('NO_COLOR');
+	// set security headers
+	app.use(securityHeaders);
 
-	if (morganLogFormat === 'dev' && noColor) {
-		morganLogFormat = ':method :url :status :response-time ms - :res[content-length]';
+	// generate nonce value
+	if (Configuration.get('CORS')) {
+		app.use(nonceValueSet);
 	}
 
-	app.use(morgan(morganLogFormat));
-}
+	// set cors headers
+	app.use(cors);
 
-app.use(bodyParser.json({ limit: '10mb' }));
-app.use(bodyParser.urlencoded({ extended: true, limit: '10mb' }));
-app.use(cookieParser());
+	app.use(compression());
+	app.set('trust proxy', true);
+	const themeName = SC_THEME;
 
-staticAssetsMiddleware(app);
+	const wax = handlebarsWax(handlebars)
+		.partials(path.join(__dirname, 'views/**/*.{hbs,js}'))
+		.helpers(layouts)
+		.helpers(handlebarsHelper.helpers(app));
 
-let sessionStore;
-const redisUrl = REDIS_URI;
-if (redisUrl) {
-	logger.info(`Using Redis session store at '${redisUrl}'.`);
-	const client = new Redis(redisUrl);
+	wax.partials(path.join(__dirname, `theme/${themeName}/views/**/*.{hbs,js}`));
 
-	// The error event must be handled, otherwise the app crashes on redis connection errors.
-	// This is due to basic NodeJS behavior: https://nodejs.org/api/events.html#error-events
-	client.on('error', (err) => {
-		logger.error('Redis client error', err);
-	});
+	const viewDirs = [path.join(__dirname, 'views')];
+	viewDirs.unshift(path.join(__dirname, `theme/${themeName}/views/`));
 
-	sessionStore = new RedisStore({ client });
-} else {
-	logger.info('Using in-memory session store.');
-	sessionStore = new session.MemoryStore();
-}
-
-const SIX_HOURS = 1000 * 60 * 60 * 6;
-app.use(session({
-	cookie: {
-		httpOnly: true,
-		sameSite: Configuration.get('SESSION_COOKIE_SAME_SITE'),
-		secure: 'auto',
-		maxAge: SIX_HOURS,
-	},
-	rolling: true, // refresh session with every request within maxAge
-	store: sessionStore,
-	saveUninitialized: true,
-	resave: false,
-	secret: Configuration.get('COOKIE_SECRET'), // Secret used to sign the session ID cookie
-}));
-
-// CSRF middlewares
-if (Configuration.get('FEATURE_CSRF_ENABLED')) {
-	app.use(duplicateTokenHandler);
-	app.use(csurf());
-	app.use(tokenInjector);
-	// there follows an csrf error handler below...
-}
+	app.set('views', viewDirs);
+	app.engine('hbs', wax.engine);
+	app.set('view engine', 'hbs');
 
-const setTheme = require('./helpers/theme');
+	app.set('view cache', true);
 
-function removeIds(url) {
-	const checkForHexRegExp = /[a-f\d]{24}/ig;
-	return url.replace(checkForHexRegExp, 'ID');
-}
-
-// Custom flash middleware
-app.use(async (req, res, next) => {
-	// if there's a flash message in the session request, make it available in the response, then delete it
-	res.locals.notification = req.session.notification;
-	res.locals.inline = req.query.inline || false;
-	setTheme(res);
-	res.locals.domain = SC_DOMAIN;
-	res.locals.production = req.app.get('env') === 'production';
-	res.locals.env = req.app.get('env') || false; // TODO: ist das false hier nicht quatsch?
-	res.locals.JWT_SHOW_TIMEOUT_WARNING_SECONDS = Number(JWT_SHOW_TIMEOUT_WARNING_SECONDS);
-	res.locals.MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE = Number(MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE);
-	// eslint-disable-next-line max-len
-	res.locals.MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_MEGABYTE = (MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE / 1024 / 1024);
-	res.locals.JWT_TIMEOUT_SECONDS = Number(JWT_TIMEOUT_SECONDS);
-	res.locals.API_HOST = PUBLIC_BACKEND_URL || `${API_HOST}/`;
-	res.locals.version = version;
-	res.locals.sha = sha;
-	res.locals.ROCKETCHAT_SERVICE_ENABLED = Configuration.get('ROCKETCHAT_SERVICE_ENABLED');
-	delete req.session.notification;
-	return next();
-});
-
-app.use(methodOverride('_method')); // for GET requests
-app.use(methodOverride((req, res, next) => { // for POST requests
-	if (req.body && typeof req.body === 'object' && '_method' in req.body) {
-		// eslint-disable-next-line no-underscore-dangle
-		const method = req.body._method;
-		// eslint-disable-next-line no-underscore-dangle
-		delete req.body._method;
-		return method;
-	}
-	return undefined;
-}));
+	// uncomment after placing your favicon in /public
+	// app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
+	if (Configuration.get('FEATURE_MORGAN_LOG_ENABLED')) {
+		let morganLogFormat = Configuration.get('MORGAN_LOG_FORMAT');
+		const noColor = Configuration.has('NO_COLOR') && Configuration.get('NO_COLOR');
 
-// add res.$t method for i18n with users prefered language
-app.use(require('./middleware/i18n'));
-app.use(require('./middleware/datetime'));
+		if (morganLogFormat === 'dev' && noColor) {
+			morganLogFormat = ':method :url :status :response-time ms - :res[content-length]';
+		}
 
-const redirectUrl = Configuration.get('ROOT_URL_REDIRECT');
-if (redirectUrl !== '') {
-	app.get('/', (req, res, next) => {
-		res.redirect(redirectUrl);
-	});
-}
-
-// Initialize the modules and their routes
-app.use(require('./controllers'));
-
-// catch 404 and forward to error handler
-app.use((req, res, next) => {
-	const url = req.originalUrl || req.url;
-	const err = new Error(`Page Not Found ${url}`);
-	err.status = 404;
-	next(err);
-});
-
-// error handlers
-if (Configuration.get('FEATURE_CSRF_ENABLED')) {
-	app.use(csrfErrorHandler);
-}
-
-// no statusCode exist for this cases
-const isTimeoutError = (err) => err && err.message && (
-	err.message.includes('ESOCKETTIMEDOUT')
-	|| err.message.includes('ECONNREFUSED')
-	|| err.message.includes('ETIMEDOUT')
-);
-
-const errorHandler = (err) => {
-	const error = err.error || err;
-	const status = error.status || error.statusCode || 500;
-	error.statusCode = status;
-
-	// prevent logging jwts and x-api-keys
-	if (error.options && error.options.headers) {
-		delete error.options.headers;
+		app.use(morgan(morganLogFormat));
 	}
 
-	return { error, status };
-};
+	app.use(bodyParser.json({ limit: '10mb' }));
+	app.use(bodyParser.urlencoded({ extended: true, limit: '10mb' }));
+	app.use(cookieParser());
 
-app.use((err, req, res, next) => {
-	const { error, status } = errorHandler(err);
+	staticAssetsMiddleware(app);
 
-	if (!res.locals) {
-		res.locals = {};
-	}
+	await initializeSessionStorage(app);
 
-	if (Configuration.get('FEATURE_LOG_REQUEST') === true) {
-		const reqInfo = {
-			url: req.originalUrl || req.url,
-			method: req.originalMethod || req.method,
-			params: req.params,
-			body: req.body,
-		};
-		error.requestInfo = filterLog(reqInfo);
+	// CSRF middlewares
+	if (Configuration.get('FEATURE_CSRF_ENABLED')) {
+		app.use(duplicateTokenHandler);
+		app.use(csurf());
+		app.use(tokenInjector);
+		// there follows an csrf error handler below...
 	}
 
-	if (res.locals.currentUser) {
-		res.locals.loggedin = true;
-		const { _id, schoolId, roles } = res.locals.currentUser;
-		error.currentUser = {
-			userId: _id,
-			schoolId,
-			roles: (roles || []).map((r) => r.name),
-		};
-	}
+	// Custom flash middleware
+	app.use(async (req, res, next) => {
+		// if there's a flash message in the session request, make it available in the response, then delete it
+		res.locals.notification = req.session.notification;
+		res.locals.inline = req.query.inline || false;
+		setTheme(res);
+		res.locals.domain = SC_DOMAIN;
+		res.locals.production = req.app.get('env') === 'production';
+		res.locals.env = req.app.get('env') || false; // TODO: ist das false hier nicht quatsch?
+		res.locals.JWT_SHOW_TIMEOUT_WARNING_SECONDS = Number(
+			JWT_SHOW_TIMEOUT_WARNING_SECONDS,
+		);
+		res.locals.MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE = Number(
+			MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE,
+		);
+		// eslint-disable-next-line max-len
+		res.locals.MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_MEGABYTE =			MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE / 1024 / 1024;
+		res.locals.JWT_TIMEOUT_SECONDS = Number(JWT_TIMEOUT_SECONDS);
+		res.locals.API_HOST = PUBLIC_BACKEND_URL || `${API_HOST}/`;
+		res.locals.version = version;
+		res.locals.sha = sha;
+		res.locals.ROCKETCHAT_SERVICE_ENABLED = Configuration.get(
+			'ROCKETCHAT_SERVICE_ENABLED',
+		);
+		delete req.session.notification;
+		return next();
+	});
 
-	if (error.message) {
-		res.setHeader('error-message', error.message);
-		res.locals.message = error.message;
-	} else {
-		res.locals.message = `Error with statusCode ${status}`;
+	app.use(methodOverride('_method')); // for GET requests
+	app.use(
+		methodOverride((req, res, next) => {
+			// for POST requests
+			if (
+				req.body
+				&& typeof req.body === 'object'
+				&& '_method' in req.body
+			) {
+				// eslint-disable-next-line no-underscore-dangle
+				const method = req.body._method;
+				// eslint-disable-next-line no-underscore-dangle
+				delete req.body._method;
+				return method;
+			}
+			return undefined;
+		}),
+	);
+
+	// add res.$t method for i18n with users prefered language
+	app.use(i18nMiddleware);
path: app.js
user: login: CeEv
body: Note noch mal schauen ob eine der Middlewares sich beim import direkt initalisiert.
created_at: 2025-05-05T09:54:19Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,126 @@
+const dns = require('dns');
+const Redis = require('iovalkey');
+const RedisStore = require('connect-redis').default;
+const session = require('express-session');
+const util = require('util');
+const { Configuration } = require('@hpi-schul-cloud/commons');
+const { logger } = require('.');
+const {
+	SESSION_VALKEY_URI,
+	SESSION_VALKEY_CLUSTER_ENABLED,
+	SESSION_VALKEY_SENTINEL_NAME,
+	SESSION_VALKEY_SENTINEL_PASSWORD,
+	SESSION_VALKEY_SENTINEL_SERVICE_NAME,
+} = require('../config/global');
+
+function getValkeyInstance(params) {
+	const redisInstance = new Redis(params);
+	redisInstance.on('error', (error) => logger.error(error));
+	redisInstance.on('connect', (msg) => logger.info(msg));
+
+	return redisInstance;
+}
+
+async function discoverSentinelHosts() {
+	const serviceName = SESSION_VALKEY_SENTINEL_SERVICE_NAME;
+	if (!serviceName) {
+		throw new Error(
+			'SENTINEL_SERVICE_NAME is required for service discovery',
+		);
+	}
+
+	const resolveSrv = util.promisify(dns.resolveSrv);
+	try {
+		const records = await resolveSrv(serviceName);
+
+		const hosts = records.map((record) => ({
+			host: record.name,
+			port: record.port,
+		}));
+
+		return hosts;
+	} catch (err) {
+		logger.info('Error during service discovery:');
+		throw err;
+	}
+}
+
+function createNewValkeyInstance() {
+	const redisUri = SESSION_VALKEY_URI;
+	if (!redisUri) {
+		throw new Error('URI is required for creating a new Valkey instance');
+	}
+
+	const redisInstance = getValkeyInstance(redisUri);
+	logger.info(`Using Valkey session store at '${redisUri}'.`);
+
+	return redisInstance;
+}
+
+async function createValkeySentinelInstance() {
+	const sentinelName = SESSION_VALKEY_SENTINEL_NAME;
+	const sentinelPassword = SESSION_VALKEY_SENTINEL_PASSWORD;
+	if (!sentinelName) {
+		throw new Error(
+			'SENTINEL_NAME is required for creating a Valkey Sentinel instance',
+		);
+	}
+
+	if (!sentinelPassword) {
+		throw new Error(
+			'SENTINEL_PASSWORD is required for creating a Valkey Sentinel instance',
+		);
+	}
+
+	const sentinels = await discoverSentinelHosts();
+	logger.info(`Using discovered Valkey sentinels: ${JSON.stringify(sentinels)}`);
+
+	const redisInstance = getValkeyInstance({
+		sentinels,
+		sentinelPassword,
+		password: sentinelPassword,
+		name: sentinelName,
+	});
+
+	return redisInstance;
+}
+
+function initSessionMiddleware(app, store) {
+	const SIX_HOURS = 1000 * 60 * 60 * 6;
+	app.use(
+		session({
+			cookie: {
+				httpOnly: true,
+				sameSite: Configuration.get('SESSION_COOKIE_SAME_SITE'),
+				secure: 'auto',
+				maxAge: SIX_HOURS,
+			},
+			rolling: true, // refresh session with every request within maxAge
+			store,
+			saveUninitialized: true,
+			resave: false,
+			secret: Configuration.get('COOKIE_SECRET'), // Secret used to sign the session ID cookie
+		}),
+	);
+}
+
+const initializeSessionStorage = async (app) => {
+	let sessionStore = null;
+
+	if (SESSION_VALKEY_CLUSTER_ENABLED) {
+		const client = await createValkeySentinelInstance();
+		sessionStore = new RedisStore({ client });
+	} else if (SESSION_VALKEY_URI && !SESSION_VALKEY_CLUSTER_ENABLED) {
+		const client = createNewValkeyInstance();
+		sessionStore = new RedisStore({ client });
+	} else {
+		sessionStore = new session.MemoryStore();
path: helpers/sessionStorage.js
user: login: CeEv
body: Do it work?
created_at: 2025-05-05T10:01:51Z
author_association: CONTRIBUTOR
diff_hunk: @@ -31,281 +29,268 @@ const {
 	KEEP_ALIVE,
 	SC_DOMAIN,
 	SC_THEME,
-	REDIS_URI,
 	JWT_SHOW_TIMEOUT_WARNING_SECONDS,
 	MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE,
 	JWT_TIMEOUT_SECONDS,
 	API_HOST,
 	PUBLIC_BACKEND_URL,
 } = require('./config/global');
-
-const app = express();
-
-// print current configuration
-Configuration.printHierarchy();
-
-// setup prometheus metrics
-prometheus(app);
-
-// template stuff
 const authHelper = require('./helpers/authentication');
-
-// set custom response header for ha proxy
-if (KEEP_ALIVE) {
-	app.use((req, res, next) => {
-		res.setHeader('Connection', 'Keep-Alive');
-		next();
-	});
-}
-
-// disable x-powered-by header
-app.disable('x-powered-by');
-
-// set security headers
 const securityHeaders = require('./middleware/security_headers');
-
-app.use(securityHeaders);
-
-// generate nonce value
-if (Configuration.get('CORS')) {
-	app.use(nonceValueSet);
-}
-
-// set cors headers
-app.use(require('./middleware/cors'));
-
-app.use(compression());
-app.set('trust proxy', true);
-const themeName = SC_THEME;
-// view engine setup
+const cors = require('./middleware/cors');
 const handlebarsHelper = require('./helpers/handlebars');
+const setTheme = require('./helpers/theme');
+const controllers = require('./controllers');
+const i18nMiddleware = require('./middleware/i18n');
+const datetimeMiddleware = require('./middleware/datetime');
 
-const wax = handlebarsWax(handlebars)
-	.partials(path.join(__dirname, 'views/**/*.{hbs,js}'))
-	.helpers(layouts)
-	.helpers(handlebarsHelper.helpers(app));
+const setupApp = async () => {
+	const app = express();
 
-wax.partials(path.join(__dirname, `theme/${themeName}/views/**/*.{hbs,js}`));
+	// print current configuration
+	Configuration.printHierarchy();
 
-const viewDirs = [path.join(__dirname, 'views')];
-viewDirs.unshift(path.join(__dirname, `theme/${themeName}/views/`));
+	// setup prometheus metrics
+	prometheus(app);
 
-app.set('views', viewDirs);
-app.engine('hbs', wax.engine);
-app.set('view engine', 'hbs');
+	// set custom response header for ha proxy
+	if (KEEP_ALIVE) {
+		app.use((req, res, next) => {
+			res.setHeader('Connection', 'Keep-Alive');
+			next();
+		});
+	}
 
-app.set('view cache', true);
+	// disable x-powered-by header
+	app.disable('x-powered-by');
 
-// uncomment after placing your favicon in /public
-// app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
-if (Configuration.get('FEATURE_MORGAN_LOG_ENABLED')) {
-	let morganLogFormat = Configuration.get('MORGAN_LOG_FORMAT');
-	const noColor = Configuration.has('NO_COLOR') && Configuration.get('NO_COLOR');
+	// set security headers
+	app.use(securityHeaders);
 
-	if (morganLogFormat === 'dev' && noColor) {
-		morganLogFormat = ':method :url :status :response-time ms - :res[content-length]';
+	// generate nonce value
+	if (Configuration.get('CORS')) {
+		app.use(nonceValueSet);
 	}
 
-	app.use(morgan(morganLogFormat));
-}
+	// set cors headers
+	app.use(cors);
 
-app.use(bodyParser.json({ limit: '10mb' }));
-app.use(bodyParser.urlencoded({ extended: true, limit: '10mb' }));
-app.use(cookieParser());
+	app.use(compression());
+	app.set('trust proxy', true);
+	const themeName = SC_THEME;
 
-staticAssetsMiddleware(app);
+	const wax = handlebarsWax(handlebars)
+		.partials(path.join(__dirname, 'views/**/*.{hbs,js}'))
+		.helpers(layouts)
+		.helpers(handlebarsHelper.helpers(app));
 
-let sessionStore;
-const redisUrl = REDIS_URI;
-if (redisUrl) {
-	logger.info(`Using Redis session store at '${redisUrl}'.`);
-	const client = new Redis(redisUrl);
+	wax.partials(path.join(__dirname, `theme/${themeName}/views/**/*.{hbs,js}`));
 
-	// The error event must be handled, otherwise the app crashes on redis connection errors.
-	// This is due to basic NodeJS behavior: https://nodejs.org/api/events.html#error-events
-	client.on('error', (err) => {
-		logger.error('Redis client error', err);
-	});
+	const viewDirs = [path.join(__dirname, 'views')];
+	viewDirs.unshift(path.join(__dirname, `theme/${themeName}/views/`));
 
-	sessionStore = new RedisStore({ client });
-} else {
-	logger.info('Using in-memory session store.');
-	sessionStore = new session.MemoryStore();
-}
-
-const SIX_HOURS = 1000 * 60 * 60 * 6;
-app.use(session({
-	cookie: {
-		httpOnly: true,
-		sameSite: Configuration.get('SESSION_COOKIE_SAME_SITE'),
-		secure: 'auto',
-		maxAge: SIX_HOURS,
-	},
-	rolling: true, // refresh session with every request within maxAge
-	store: sessionStore,
-	saveUninitialized: true,
-	resave: false,
-	secret: Configuration.get('COOKIE_SECRET'), // Secret used to sign the session ID cookie
-}));
-
-// CSRF middlewares
-if (Configuration.get('FEATURE_CSRF_ENABLED')) {
-	app.use(duplicateTokenHandler);
-	app.use(csurf());
-	app.use(tokenInjector);
-	// there follows an csrf error handler below...
-}
+	app.set('views', viewDirs);
+	app.engine('hbs', wax.engine);
+	app.set('view engine', 'hbs');
 
-const setTheme = require('./helpers/theme');
+	app.set('view cache', true);
 
-function removeIds(url) {
-	const checkForHexRegExp = /[a-f\d]{24}/ig;
-	return url.replace(checkForHexRegExp, 'ID');
-}
-
-// Custom flash middleware
-app.use(async (req, res, next) => {
-	// if there's a flash message in the session request, make it available in the response, then delete it
-	res.locals.notification = req.session.notification;
-	res.locals.inline = req.query.inline || false;
-	setTheme(res);
-	res.locals.domain = SC_DOMAIN;
-	res.locals.production = req.app.get('env') === 'production';
-	res.locals.env = req.app.get('env') || false; // TODO: ist das false hier nicht quatsch?
-	res.locals.JWT_SHOW_TIMEOUT_WARNING_SECONDS = Number(JWT_SHOW_TIMEOUT_WARNING_SECONDS);
-	res.locals.MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE = Number(MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE);
-	// eslint-disable-next-line max-len
-	res.locals.MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_MEGABYTE = (MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE / 1024 / 1024);
-	res.locals.JWT_TIMEOUT_SECONDS = Number(JWT_TIMEOUT_SECONDS);
-	res.locals.API_HOST = PUBLIC_BACKEND_URL || `${API_HOST}/`;
-	res.locals.version = version;
-	res.locals.sha = sha;
-	res.locals.ROCKETCHAT_SERVICE_ENABLED = Configuration.get('ROCKETCHAT_SERVICE_ENABLED');
-	delete req.session.notification;
-	return next();
-});
-
-app.use(methodOverride('_method')); // for GET requests
-app.use(methodOverride((req, res, next) => { // for POST requests
-	if (req.body && typeof req.body === 'object' && '_method' in req.body) {
-		// eslint-disable-next-line no-underscore-dangle
-		const method = req.body._method;
-		// eslint-disable-next-line no-underscore-dangle
-		delete req.body._method;
-		return method;
-	}
-	return undefined;
-}));
+	// uncomment after placing your favicon in /public
+	// app.use(favicon(path.join(__dirname, 'public', 'favicon.ico')));
+	if (Configuration.get('FEATURE_MORGAN_LOG_ENABLED')) {
+		let morganLogFormat = Configuration.get('MORGAN_LOG_FORMAT');
+		const noColor = Configuration.has('NO_COLOR') && Configuration.get('NO_COLOR');
 
-// add res.$t method for i18n with users prefered language
-app.use(require('./middleware/i18n'));
-app.use(require('./middleware/datetime'));
+		if (morganLogFormat === 'dev' && noColor) {
+			morganLogFormat = ':method :url :status :response-time ms - :res[content-length]';
+		}
 
-const redirectUrl = Configuration.get('ROOT_URL_REDIRECT');
-if (redirectUrl !== '') {
-	app.get('/', (req, res, next) => {
-		res.redirect(redirectUrl);
-	});
-}
-
-// Initialize the modules and their routes
-app.use(require('./controllers'));
-
-// catch 404 and forward to error handler
-app.use((req, res, next) => {
-	const url = req.originalUrl || req.url;
-	const err = new Error(`Page Not Found ${url}`);
-	err.status = 404;
-	next(err);
-});
-
-// error handlers
-if (Configuration.get('FEATURE_CSRF_ENABLED')) {
-	app.use(csrfErrorHandler);
-}
-
-// no statusCode exist for this cases
-const isTimeoutError = (err) => err && err.message && (
-	err.message.includes('ESOCKETTIMEDOUT')
-	|| err.message.includes('ECONNREFUSED')
-	|| err.message.includes('ETIMEDOUT')
-);
-
-const errorHandler = (err) => {
-	const error = err.error || err;
-	const status = error.status || error.statusCode || 500;
-	error.statusCode = status;
-
-	// prevent logging jwts and x-api-keys
-	if (error.options && error.options.headers) {
-		delete error.options.headers;
+		app.use(morgan(morganLogFormat));
 	}
 
-	return { error, status };
-};
+	app.use(bodyParser.json({ limit: '10mb' }));
+	app.use(bodyParser.urlencoded({ extended: true, limit: '10mb' }));
+	app.use(cookieParser());
 
-app.use((err, req, res, next) => {
-	const { error, status } = errorHandler(err);
+	staticAssetsMiddleware(app);
 
-	if (!res.locals) {
-		res.locals = {};
-	}
+	await initializeSessionStorage(app);
 
-	if (Configuration.get('FEATURE_LOG_REQUEST') === true) {
-		const reqInfo = {
-			url: req.originalUrl || req.url,
-			method: req.originalMethod || req.method,
-			params: req.params,
-			body: req.body,
-		};
-		error.requestInfo = filterLog(reqInfo);
+	// CSRF middlewares
+	if (Configuration.get('FEATURE_CSRF_ENABLED')) {
+		app.use(duplicateTokenHandler);
+		app.use(csurf());
+		app.use(tokenInjector);
+		// there follows an csrf error handler below...
 	}
 
-	if (res.locals.currentUser) {
-		res.locals.loggedin = true;
-		const { _id, schoolId, roles } = res.locals.currentUser;
-		error.currentUser = {
-			userId: _id,
-			schoolId,
-			roles: (roles || []).map((r) => r.name),
-		};
-	}
+	// Custom flash middleware
+	app.use(async (req, res, next) => {
+		// if there's a flash message in the session request, make it available in the response, then delete it
+		res.locals.notification = req.session.notification;
+		res.locals.inline = req.query.inline || false;
+		setTheme(res);
+		res.locals.domain = SC_DOMAIN;
+		res.locals.production = req.app.get('env') === 'production';
+		res.locals.env = req.app.get('env') || false; // TODO: ist das false hier nicht quatsch?
+		res.locals.JWT_SHOW_TIMEOUT_WARNING_SECONDS = Number(
+			JWT_SHOW_TIMEOUT_WARNING_SECONDS,
+		);
+		res.locals.MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE = Number(
+			MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE,
+		);
+		// eslint-disable-next-line max-len
+		res.locals.MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_MEGABYTE =			MAXIMUM_ALLOWABLE_TOTAL_ATTACHMENTS_SIZE_BYTE / 1024 / 1024;
+		res.locals.JWT_TIMEOUT_SECONDS = Number(JWT_TIMEOUT_SECONDS);
+		res.locals.API_HOST = PUBLIC_BACKEND_URL || `${API_HOST}/`;
+		res.locals.version = version;
+		res.locals.sha = sha;
+		res.locals.ROCKETCHAT_SERVICE_ENABLED = Configuration.get(
+			'ROCKETCHAT_SERVICE_ENABLED',
+		);
+		delete req.session.notification;
+		return next();
+	});
 
-	if (error.message) {
-		res.setHeader('error-message', error.message);
-		res.locals.message = error.message;
-	} else {
-		res.locals.message = `Error with statusCode ${status}`;
+	app.use(methodOverride('_method')); // for GET requests
+	app.use(
+		methodOverride((req, res, next) => {
+			// for POST requests
+			if (
+				req.body
+				&& typeof req.body === 'object'
+				&& '_method' in req.body
+			) {
+				// eslint-disable-next-line no-underscore-dangle
+				const method = req.body._method;
+				// eslint-disable-next-line no-underscore-dangle
+				delete req.body._method;
+				return method;
+			}
+			return undefined;
+		}),
+	);
+
+	// add res.$t method for i18n with users prefered language
+	app.use(i18nMiddleware);
path: app.js
user: login: CeEv
body: passt
created_at: 2025-05-06T06:48:13Z
author_association: CONTRIBUTOR

PR comments:
user: login: sonarqubecloud[bot]
created_at: 2025-05-06T07:04:41Z
body: ## [![Quality Gate Passed](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/checks/QualityGateBadge/qg-passed-20px.png 'Quality Gate Passed')](https://sonarcloud.io/dashboard?id=hpi-schul-cloud_schulcloud-client&pullRequest=3627) **Quality Gate passed**  
Issues  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [1 New issue](https://sonarcloud.io/project/issues?id=hpi-schul-cloud_schulcloud-client&pullRequest=3627&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/accepted-16px.png '') [0 Accepted issues](https://sonarcloud.io/project/issues?id=hpi-schul-cloud_schulcloud-client&pullRequest=3627&issueStatuses=ACCEPTED)

Measures  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0 Security Hotspots](https://sonarcloud.io/project/security_hotspots?id=hpi-schul-cloud_schulcloud-client&pullRequest=3627&issueStatuses=OPEN,CONFIRMED&sinceLeakPeriod=true)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Coverage on New Code](https://sonarcloud.io/component_measures?id=hpi-schul-cloud_schulcloud-client&pullRequest=3627&metric=new_coverage&view=list)  
![](https://sonarsource.github.io/sonarcloud-github-static-resources/v2/common/passed-16px.png '') [0.0% Duplication on New Code](https://sonarcloud.io/component_measures?id=hpi-schul-cloud_schulcloud-client&pullRequest=3627&metric=new_duplicated_lines_density&view=list)  
  
[See analysis details on SonarQube Cloud](https://sonarcloud.io/dashboard?id=hpi-schul-cloud_schulcloud-client&pullRequest=3627)


author_association: NONE