Bei der Entwicklung von Wehrkalender stand früh eine zentrale Frage im Raum:
Wie stellen wir sicher, dass jede Feuerwehr nur ihre eigenen Daten sieht - und sich gleichzeitig online wie in ihrem eigenen System fühlt?
Die Antwort war keine komplizierte Enterprise-Abstraktion, sondern eine robuste Kombination aus drei Bausteinen:
- eigene Subdomain pro Feuerwehr
- signierter JWT mit Tenant-Claim
- separates Datenbank-Schema pro Mandant
Das Ergebnis: Jede Feuerwehr bekommt ihre eigene Adresse, während die Anwendung intern jeden Request automatisch dem richtigen Mandanten zuordnet.
feuerwehr-musterstadt.wehrkalender.de
Damit ist der Tenant nicht bloß ein URL-Parameter oder eine Organisations-ID im Frontend. Er wird zum festen Bestandteil der Architektur.
Warum Subdomains für Multitenancy?
Subdomains lösen zwei Probleme gleichzeitig: Sie schaffen Identität nach außen und Klarheit im Systeminneren.
Nach außen: ein eigener digitaler Ort
Für die Feuerwehr fühlt sich eine eigene Subdomain nicht wie ein Login in ein fremdes SaaS-Tool an, sondern wie ein eigener Bereich:
- eigene Web-Adresse
- nutzbar auf Website, Dokumenten und Visitenkarten
- klare Wiedererkennbarkeit für Mitglieder
- weniger Verwirrung beim Login
Gerade bei Organisationen mit vielen ehrenamtlichen Nutzerinnen und Nutzern ist das wichtig. Die URL sagt bereits: Du bist hier richtig.
Intern: weniger Risiko durch klare Grenzen
Technisch sorgt die Subdomain dafür, dass der Mandant früh im Request-Lifecycle erkennbar ist. Kombiniert mit einem signierten JWT und Schema-Separation entsteht daraus eine starke Sicherheitsgrenze:
- Der Tenant wird aus dem JWT gelesen, nicht aus frei manipulierbaren Query-Parametern.
- Hibernate arbeitet automatisch im Schema des aktuellen Tenants.
- Fachlogik muss nicht in jeder Query
tenant_id = ?ergänzen. - Rollen und Berechtigungen können pro Feuerwehr unterschiedlich sein.
Diese Kombination macht Mandanten-Isolation nicht zu einer nachträglichen Prüfung, sondern zu einem festen Bestandteil der Architektur.
Architekturüberblick
Der Request läuft durch mehrere Schichten, die jeweils eine klare Aufgabe haben:
Subdomain
↓
Nginx / Reverse Proxy
↓
Frontend erkennt Tenant aus Hostname
↓
Login erzeugt JWT mit Tenant-Claim
↓
Spring Boot validiert JWT
↓
TenantContext wird pro Request gesetzt
↓
Hibernate wählt automatisch das passende DB-Schema
Die zentrale Idee: Der Tenant wird einmal sauber bestimmt und danach nicht mehr manuell durch die Anwendung gereicht.
1. Wildcard-Subdomains mit Nginx
Alle Feuerwehr-Subdomains zeigen auf dieselbe Anwendung. Der Hostname wird an das Frontend und Backend weitergegeben.
server {
server_name *.wehrkalender.de;
location / {
proxy_pass http://frontend;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header X-Real-IP $remote_addr;
}
}
Dazu kommt ein Wildcard-DNS-Eintrag:
*.wehrkalender.de A <Frontend-IP>
Für TLS kann ein Wildcard-Zertifikat verwendet werden, zum Beispiel über eine DNS-Challenge:
certbot certonly \
--dns-cloudflare \
-d '*.wehrkalender.de' \
-d 'wehrkalender.de'
Der Reverse Proxy bildet damit die erste klare Eingangslinie: Jede Feuerwehr-Subdomain landet zuverlässig in derselben Anwendung, aber mit ihrem eigenen Host-Kontext.
2. Tenant-Erkennung im Frontend
Im Frontend wird der Tenant aus dem Hostnamen gelesen. Das ist vor allem für den Login und tenant-spezifische UI-Anpassungen nützlich.
export function getTenantFromUrl(): string {
const hostname = window.location.hostname;
const parts = hostname.split(".");
if (parts.length > 2) {
return parts[0];
}
throw new Error("No tenant found in subdomain");
}
Beim Login wird der erkannte Tenant mitgesendet:
const response = await fetch(`/api/auth/login?tenant=${getTenantFromUrl()}`, {
method: "POST",
headers: {
"Content-Type": "application/json",
},
body: JSON.stringify({ email, password }),
});
Der Frontend-Tenant sorgt für die richtige Login-Erfahrung. Die verbindliche Mandanten-Zuordnung entsteht anschließend durch den signierten Token des Authorization Servers.
3. JWT mit festem Tenant-Claim
Nach erfolgreicher Authentifizierung stellt der Auth-Provider einen JWT aus. Darin steckt der Tenant als Claim.
{
"sub": "user-uuid-12345",
"tenant": "feuerwehr-musterstadt",
"email": "user@feuerwehr-musterstadt.de",
"realm_access": {
"roles": ["Wehrführer", "Admin"]
},
"iat": 1777039200,
"exp": 1777042800
}
Dieser Claim ist entscheidend: Das Backend vertraut nicht dem Query-Parameter, sondern dem validierten JWT.
Wird der Payload manipuliert, passt die digitale Signatur nicht mehr. Der Token wird abgelehnt, bevor ein Controller erreicht wird.
In produktiven Setups wird dafür häufig ein asymmetrisches Verfahren wie RS256 oder ES256 verwendet. Das Backend validiert den Token anhand des Public Keys des Authorization Servers.
4. Tenant-Context in Spring Boot setzen
Jeder Request läuft durch einen Interceptor. Dieser extrahiert den Token, liest den Tenant-Claim und setzt den Tenant für die Dauer der Request in einen Context.
@RequiredArgsConstructor
@Component("multiTenantInterceptor")
public class MultiTenantJwtInterceptor implements HandlerInterceptor {
private final TenantService tenantService;
private final JwtDecoder jwtDecoder;
private final BearerTokenResolver tokenResolver = new DefaultBearerTokenResolver();
@Override
public boolean preHandle(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull Object handler
) {
String token = tokenResolver.resolve(request);
if (token == null) {
throw new InvalidBearerTokenException("Missing bearer token");
}
String tenantId = jwtDecoder.decode(token).getClaimAsString("tenant");
Tenant tenant = tenantService.getBySchema(tenantId)
.orElseThrow(() -> new InvalidBearerTokenException("Unknown tenant: " + tenantId));
TenantContext.setTenant(tenant);
return true;
}
@Override
public void afterCompletion(
@NonNull HttpServletRequest request,
@NonNull HttpServletResponse response,
@NonNull Object handler,
Exception ex
) {
TenantContext.clear();
}
}
Wichtig ist das Cleanup in afterCompletion(). Dadurch wird der Tenant auch dann entfernt, wenn im Controller oder Service eine Exception entsteht.
5. TenantContext mit ThreadLocal
Der TenantContext hält den aktiven Mandanten pro Request-Thread.
public final class TenantContext {
private static final ThreadLocal<Tenant> TENANT = new ThreadLocal<>();
private TenantContext() {
}
public static Tenant getTenant() {
return TENANT.get();
}
public static void setTenant(Tenant tenant) {
TENANT.set(tenant);
}
public static void clear() {
TENANT.remove();
}
}
ThreadLocal ist hier bewusst simpel. Jeder Request bekommt seinen eigenen Tenant-Kontext.
Für @Async, Executor Pools oder Virtual Threads lässt sich der gleiche Ansatz gezielt über Context Propagation oder einen TaskDecorator erweitern. So bleibt der Tenant auch außerhalb des ursprünglichen Request-Threads eindeutig.
6. Automatische Schema-Auswahl mit Hibernate
Sobald der Tenant im Context liegt, kann Hibernate automatisch das richtige Datenbank-Schema verwenden.
@Component
public class CurrentTenantResolver implements CurrentTenantIdentifierResolver<String> {
public static final String DEFAULT_SCHEMA = "wehrkalender";
@Override
public String resolveCurrentTenantIdentifier() {
Tenant tenant = TenantContext.getTenant();
if (tenant == null) {
return DEFAULT_SCHEMA;
}
return tenant.getSchemaName();
}
@Override
public boolean validateExistingCurrentSessions() {
return true;
}
}
Damit läuft eine fachliche Query wie diese:
SELECT * FROM events WHERE user_id = ?;
automatisch im Schema des aktuellen Mandanten, zum Beispiel:
tenant_feuerwehr_musterstadt
Der Anwendungscode bleibt dadurch deutlich einfacher. Services und Repositories müssen den Tenant nicht ständig als Parameter mitführen.
7. Geteilte User-Base, lokale Rollen
Wehrkalender nutzt eine geteilte User-Base. Ein Nutzer kann also Mitglied mehrerer Feuerwehren sein.
Die Zuordnung liegt im Default-Schema:
@Entity
@Table(name = "user_tenants", schema = "wehrkalender")
public class UserTenant {
@EmbeddedId
private UserTenantId id;
// userId + tenantId
}
Die Rollen selbst liegen dagegen im jeweiligen Tenant-Schema:
@Entity
@Table(name = "roles")
public class Role {
@Id
private UUID id;
private String name;
private String description;
}
So kann dieselbe Person in unterschiedlichen Organisationen unterschiedliche Rechte haben:
alice@example.com
Feuerwehr München: Wehrführer
Feuerwehr Berlin: Einsatzleiter
Die Identität ist global. Die Berechtigung ist lokal.
8. JWT in Spring Security konvertieren
Der JWT wird in einen Spring-Security-Principal umgewandelt. Dabei werden Tenant und Rollen übernommen.
public class JwtToUserConverter implements Converter<Jwt, UsernamePasswordAuthenticationToken> {
private final TenantService tenantService;
@Override
public UsernamePasswordAuthenticationToken convert(Jwt jwt) {
UUID userId = UUID.fromString(jwt.getSubject());
String tenantSchema = jwt.getClaimAsString("tenant");
String email = jwt.getClaimAsString("email");
Tenant tenant = tenantService.getBySchema(tenantSchema)
.orElseThrow(() -> new InvalidBearerTokenException("Unknown tenant: " + tenantSchema));
List<SimpleGrantedAuthority> authorities = extractRoles(jwt);
AuthenticatedUser user = new AuthenticatedUser(
userId,
email,
tenant.getId(),
tenantSchema,
authorities
);
return new UsernamePasswordAuthenticationToken(user, jwt, authorities);
}
private List<SimpleGrantedAuthority> extractRoles(Jwt jwt) {
Map<String, Object> realmAccess = jwt.getClaim("realm_access");
if (realmAccess == null || !realmAccess.containsKey("roles")) {
return List.of();
}
List<?> roles = (List<?>) realmAccess.get("roles");
return roles.stream()
.map(Object::toString)
.map(role -> new SimpleGrantedAuthority("ROLE_" + role))
.toList();
}
}
Damit kann der Controller ganz normal mit Spring Security arbeiten:
@PreAuthorize("hasRole('Admin')")
@GetMapping("/api/events")
public List<Event> getEvents() {
return eventService.findAll();
}
Die Mandanten-Isolation passiert im Hintergrund. Die Berechtigungsprüfung bleibt lesbar.
Request-Flow im Detail
Ein typischer Request sieht so aus:
1. Nutzer öffnet:
https://feuerwehr-musterstadt.wehrkalender.de
2. Frontend erkennt:
tenant = feuerwehr-musterstadt
3. Login erzeugt einen JWT mit:
tenant = feuerwehr-musterstadt
4. Backend validiert:
- Ist der JWT signiert und gültig?
- Existiert der Tenant?
- Ist der Nutzer diesem Tenant zugeordnet?
5. TenantContext wird gesetzt:
tenant_feuerwehr_musterstadt
6. Hibernate nutzt automatisch das richtige Schema.
7. Nach der Request wird der TenantContext geleert.
Das Ergebnis ist ein durchgängiger Tenant-Flow: vom Hostnamen über den JWT bis zur Datenbankverbindung.
Security-Garantien
1. Tenant wird nicht aus unsicheren Parametern abgeleitet
Ein Query-Parameter wie ?tenant=berlin kann leicht manipuliert werden. Ein signierter JWT nicht.
Deshalb gilt:
Frontend-Subdomain: hilfreich für UX und Login
JWT-Tenant-Claim: entscheidend für Security
DB-Schema: entscheidend für Datenisolation
2. Schema-Separation reduziert Blast Radius
Wenn jede Feuerwehr ein eigenes Schema besitzt, ist die Mandantengrenze direkt in der Datenbankstruktur verankert. Fachliche Queries laufen automatisch im richtigen Kontext, ohne dass jede Abfrage manuell um eine tenant_id-Bedingung ergänzt werden muss.
Gegenüber reiner Row-Level-Multitenancy entsteht dadurch eine besonders klare Trennung: Der Anwendungscode arbeitet im aktuellen Mandantenkontext, Hibernate übernimmt die Schema-Auswahl.
3. Cleanup verhindert Tenant-Leaks zwischen Requests
Der TenantContext wird nach jeder Request konsequent geleert. Damit bleibt die Request-Isolation auch bei Thread-Wiederverwendung sauber und vorhersehbar. Der richtige Ort dafür ist ein Filter oder Interceptor mit garantiertem Cleanup.
4. Rollen bleiben tenant-spezifisch
Ein Nutzer kann in einer Feuerwehr Admin sein und in einer anderen nur Leserechte haben. Deshalb dürfen Rollen nicht global gedacht werden.
Die Kombination aus globaler Identität und lokalen Rollen macht das System flexibel, ohne die Berechtigungslogik zu verwässern.
Best Practices aus der Umsetzung
1. Hostname und JWT konsequent abgleichen
Wenn der Nutzer auf berlin.wehrkalender.de unterwegs ist, der JWT aber tenant=muenchen enthält, wird der Request abgelehnt oder ein neuer Login erzwungen.
Die klare Regel lautet:
Subdomain-Tenant muss zum JWT-Tenant passen.
2. TenantContext immer aufräumen
Der TenantContext wird nach jeder Request entfernt. Dadurch bleibt jeder Request eindeutig und unabhängig vom nächsten.
3. Async bewusst anbinden
Async-Jobs erhalten den Tenant-Kontext explizit über Context Propagation. So bleibt auch Hintergrundverarbeitung mandantengenau.
4. Schema-Namen aus dem Tenant-Modell ableiten
Schema-Namen werden nicht frei aus User Input gebaut, sondern aus dem gespeicherten Tenant-Modell geladen:
- Subdomain normalisieren
- Tenant in zentraler Tabelle nachschlagen
- gespeicherten Schema-Namen verwenden
- Schema-Namen strikt whitelisten
5. Security-Schichten bewusst kombinieren
Schema-Separation übernimmt die Mandantengrenze. Prepared Statements, ORM-Parameterbindung und Validierung übernehmen die Query-Sicherheit. Zusammen entsteht eine Architektur, die Datenzugriff und Eingabesicherheit sauber trennt.
Warum diese Architektur gut zu Wehrkalender passt
Wehrkalender ist keine anonyme Massen-SaaS-Plattform. Jede Feuerwehr ist eine reale Organisation mit eigener Struktur, eigenen Rollen und eigenen Abläufen.
Deshalb passt eine Architektur, die diese Eigenständigkeit technisch abbildet:
- eigene Subdomain
- eigene Rollen
- eigenes Datenbank-Schema
- zentral verwaltete Nutzeridentität
- klare Trennung zwischen Identität und Berechtigung
Für die Nutzer fühlt sich das System vertraut an. Für die Entwickler bleibt es beherrschbar.
Fazit
Subdomain-basierte Multitenancy mit JWT-Enforcement und Schema-Separation ist eine klare, belastbare Architektur für SaaS-Systeme mit echten Organisationsgrenzen. Sie trennt Mandanten sauber und schafft gleichzeitig eine starke Nutzererfahrung.
Die wichtigsten Architekturentscheidungen:
- Subdomains schaffen Identität und Vertrauen.
- Der Tenant gehört in einen signierten JWT, nicht in frei manipulierbare Parameter.
- Hibernate Multi-Tenancy kann Schema-Auswahl transparent lösen.
- ThreadLocal hält den Tenant-Kontext pro Request schlank und performant.
- Rollen sollten pro Tenant gedacht werden, nicht global.
So entsteht ein System, das nicht nur funktioniert, sondern sich selbst schützt.
Code-Deep-Dives
Die wichtigsten Bausteine dieser Architektur:
MultiTenantJwtInterceptor.java- Tenant-Extraktion und Context-SetupTenantContext.java- Request-lokaler Tenant HolderCurrentTenantResolver.java- Hibernate Schema-AuswahlMultiTenantAuthenticationManagerResolver.java- JWT-Validierung pro TenantJwtToUserConverter.java- JWT zu Spring Security PrincipalRole.java- tenant-spezifische RollenUserTenant.java- globale User-zu-Tenant-Zuordnung