Neuigkeiten von trion.
Immer gut informiert.

Magic Link Authentifizierung mit Spring Security und Spring Boot

Spring Boot

Bei der Anmeldung per sogenanntem Magic Link erhält der Benutzer einen Link, über den er sich ohne Passwort anmeldet. Der Link wird typischerweise per E-Mail zugestellt, der erforderliche Zugriff auf das Postfach tritt damit an die Stelle der Eingabe eines Passworts.
Spring Security bringt in neueren Versionen für dieses Verfahren mit dem One-Time-Token-Login eine fertige Unterstützung von Haus aus mit mit. In diesem Beitrag wird die Umsetzung mit dem aktuellen Spring Boot 4 und Spring Security 7 gezeigt. Dabei betrachten wir zwei Fälle: Links, die genau einmal genutzt werden können, so wie es typischerweise auch genutzt wird und auch von Spring direkt vorgesehen ist. Als Alternative wird eine Konfiguration für Links, die über einen längeren Zeitraum oder sogar dauerhaft funktionieren, vorgestellt. Für den zweiten Fall gehen wir auf die damit verbundenen Risiken, sinnvolle Einsatzszenarien und generelle Alternativen ein.
Eine lauffähige Beispielanwendung steht am Ende des Artikels zum Download bereit.

Wir gehen davon aus, dass ein Benutzer bereits ein Konto hat, dabei hat er sich einen eindeutigen Benutzernamen gewählt und seine E-Mail Adresse hinterlegt. Statt Benutzername kann natürlich auch direkt die E-Mail Adresse genutzt werden.

Der Ablauf gliedert sich in zwei Schritte: Zunächst gibt der Benutzer auf der Anmeldeseite seinen Benutzernamen, oder alternativ die E-Mail an. Die Anwendung erzeugt daraufhin einen Zufallstoken, generiert daraus einen aufrufbaren Link und leitet diesen über einen separaten Kanal an den Nutzer weiter. Typischerweise eben per E-Mail. Mit dem Aufruf des Links im Browser meldet sich der Nutzer anschließend an der Anwendung an.

Das bedeutet: Wer den Link besitzt, kann sich anmelden.
Die Sicherheit hängt also von der Vertraulichkeit des Zustellwegs und der Absicherung des Postfachs ab. Damit ein abgefangener oder Link in einer archivierten E-Mail nicht zu einem fortan bestehenden Risiko wird, sind die Tokens in Spring Security kurzlebig und nur einmal verwendbar.

Spring Security bietet die Unterstützung für dieses Verfahren seit Version 6.4 unter dem Namen One-Time-Token-Login an. In Spring Security 7, die Version, die Spring Boot 4 anzieht, ist das Verfahren fester Bestandteil der Security-DSL.

Projekt aufsetzen

Als Grundlage dient ein Maven-Projekt mit Spring Boot 4 und Java 25. Neben dem Security-Starter wird der WebMVC-Starter benötigt.
An dieser Stelle ein kleiner Hinweis: Der bisherige Starter spring-boot-starter-web wurde mit Spring Boot 4 in spring-boot-starter-webmvc umbenannt.

Abhängigkeiten im Maven-Projekt
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-webmvc</artifactId>
</dependency>
<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-security</artifactId>
</dependency>

Die Aktivierung erfolgt über die Spring Security Java-DSL mit oneTimeTokenLogin(). Eine recht kompakte Konfiguration.

Das folgende abgespeckte Beispiel zeigt ein vollständige Konfiguration mit einem festen In-Memory Beispieluser.

Security-Konfiguration mit One-Time-Token-Login
@Configuration
@EnableWebSecurity
public class SecurityConfig
{
    @Bean
    public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception
    {
        http
            .authorizeHttpRequests(authorize -> authorize
                    .requestMatchers("/ott/sent").permitAll()
                    .anyRequest().authenticated())
            .oneTimeTokenLogin(Customizer.withDefaults());

        return http.build();
    }

    @Bean
    public UserDetailsService userDetailsService()
    {
        // Demo-User: Passwort wird beim Magic-Link-Login nicht verwendet.
        final var user = User.withUsername("demo")
            .password("{noop}not-used")
            .roles("USER")
            .build();

        return new InMemoryUserDetailsManager(user);
    }
}

Spring Security stellt damit automatisch folgende Elemente bereit: Eine Anmeldeseite mit einem Formular, in dem der Benutzer seinen Benutzernamen eingibt. Der GenerateOneTimeTokenFilter nimmt die Anfrage unter POST /ott/generate entgegen und erzeugt ein zufälliges Token.
Unter GET /login/ott liefert Spring Security eine Seite aus, die das Token entgegennimmt.

Explizit nicht Bestandteil von der Defaultkonfiguration ist die Art der Zustellung des Links.
Dazu wird durch den Entwickler eine Bean vom Typ OneTimeTokenGenerationSuccessHandler implementiert, die von der Konfiguration automatisch aktiviert wird.
Im Beispielprojekt wird der Link in das Anwendungslog geschrieben, was der MagicLinkHandler übernimmt. In einer echten Anwendung würde an dieser Stelle der Versand per E-Mail erfolgen.

MagicLinkHandler generiert und loggt Magic Link
@Component
public class MagicLinkHandler implements OneTimeTokenGenerationSuccessHandler
{
    private static final Logger logger = LoggerFactory.getLogger(MagicLinkHandler.class);

    private final OneTimeTokenGenerationSuccessHandler redirectHandler =
            new RedirectOneTimeTokenGenerationSuccessHandler("/ott/sent");

    @Override
    public void handle(HttpServletRequest request, HttpServletResponse response, OneTimeToken oneTimeToken)
            throws IOException, ServletException
    {
        final var magicLink = UriComponentsBuilder.fromUriString(UrlUtils.buildFullRequestUrl(request))
                .replacePath(request.getContextPath())
                .replaceQuery(null)
                .fragment(null)
                .path("/login/ott")
                .queryParam("token", oneTimeToken.getTokenValue())
                .toUriString();

        // Beispiel statt Versand per E-Mail an den User
        logger.info("Magic Link fuer {}: {}", oneTimeToken.getUsername(), magicLink);

        this.redirectHandler.handle(request, response, oneTimeToken);
    }
}

Nach der Eingabe des Benutzernamens demo auf der Anmeldeseite wird eine entsprechende Logzeilte generiert.

Ausgabe im Anwendungslog
Magic Link fuer demo: http://localhost:8080/login/ott?token=82e02986-17f4-441e-b978-cf453419edce

Der Aufruf des Links zeigt die Submit-Seite, auf der das Token bereits vorbefüllt ist. Erst das Absenden des Formulars per POST konsumiert den Token und meldet den Benutzer an.
Das hat auch einen nicht unmittelbar offensichtlichen Hintergrund: Sicherheitsgateways und Virenscanner rufen Links in E-Mails häufig automatisiert per HTTP GET auf, um die dahinterliegende Seite zu bewerten.
Würde bereits der GET-Aufruf den Token konsumieren, wäre der Link zum Zeitpunkt des tatsächlichen Klicks durch den Nutzer schon verbraucht.

Die einmalige Nutzbarkeit setzt der standardmäßig aktive InMemoryOneTimeTokenService um. Er entfernt den Token beim Konsumieren aus dem Speicher.
Ein zweiter Aufruf desselben Links führt zurück zur Anmeldeseite mit einer Fehlermeldung. Die Gültigkeit beträgt standardmäßig fünf Minuten. Für den Betrieb mit mehreren Repliken oder auch über Neustarts der Anwendung hinweg liefert Spring Security mit dem JdbcOneTimeTokenService eine Datenbank basierte Implementierung mit. Tokens werden dann in der Tabelle one_time_tokens ablegt.

Soll die Gültigkeitsdauer angepasst werden, kann dies über einen GenerateOneTimeTokenRequestResolver erfolgen. Das zeigt das folgende Beispiel.

Angepasste Gültigkeit der Token
@Bean
public GenerateOneTimeTokenRequestResolver tokenRequestResolver()
{
    final var delegate = new DefaultGenerateOneTimeTokenRequestResolver();
    return request -> {
        final var resolved = delegate.resolve(request);
        return new GenerateOneTimeTokenRequest(resolved.getUsername(), Duration.ofMinutes(15));
    };
}

Token im URL-Pfad als Variante

Der Standard von Spring Security übergibt den Token als Query-Parameter. Das hat den Vorteil, dass dies typischeweise nicht in Proxy-Logs o.ä. auftauch.
Alternativ kann der Token aber auch im URL-Pfad transportiert werden. Der Link hat dann die Form https://example.com/magic/82e02986-…​;.

Da Spring Security dafür keinen eigenen Mechanismus mitbringt, muss ein eigener Controller erstellt werden. Der Controller liefert ein Formular aus, das den Token per POST an den Standard-Endpunkt /login/ott übergibt, der Token taucht damit zu keinem Zeitpunkt in einem Query-String auf.

Controller für Magic Links mit Token im Pfad
@RestController
public class MagicLinkPathController
{
    @GetMapping(value = "/magic/{token}", produces = MediaType.TEXT_HTML_VALUE)
    public String submitPage(@PathVariable String token, HttpServletRequest request)
    {
        final var csrf = (CsrfToken) request.getAttribute(CsrfToken.class.getName());

        return """
                <!DOCTYPE html>
                <html lang="de">
                <head><meta charset="utf-8"><title>Anmeldung</title></head>
                <body>
                <form method="post" action="/login/ott">
                  <input type="hidden" name="token" value="%s">
                  <input type="hidden" name="%s" value="%s">
                  <button type="submit">Anmelden</button>
                </form>
                </body>
                </html>
                """.formatted(
                HtmlUtils.htmlEscape(token),
                HtmlUtils.htmlEscape(csrf.getParameterName()),
                HtmlUtils.htmlEscape(csrf.getToken()));
    }
}

Der Pfad /magic/* muss in der Security-Konfiguration per permitAll() freigegeben werden, im OneTimeTokenGenerationSuccessHandler wird der Link entsprechend mit dem Pfadsegment statt des Query-Parameters generiert.
Im Beispielprojekt werden beide Linkvarianten in das Log geschrieben.

Hintergrund der Pfad-Variante: Manche E-Mail- und Chat-Programme schneiden beim automatischen Erkennen von Links Query-Strings ab. Ein reiner Pfad bleibt beim Kopieren und Umbrechen eher intakt.
Sicherheitssysteme wie Browser Tracking-Schutz oder Enterprise-Gateways behandeln Query-Parameter manchmal als zu entfernende Elemente.
Zudem ist der Link kürzer und angenehmer zu lesen.

Dem stehen aber auch Nachteile gegenüber: Die Variante ist nicht Teil des Spring-Security-Standards und erfordert eigenen Code samt Security Konfiguration für einen weiteren Endpunkt. Werkzeuge, die sensibler Daten in Logs maskieren, arbeiten häufig über bekannte Query-Parameternamen wie token, ein Token im Pfad wird davon nicht erfasst. In Access-Logs steht der Pfad zudem praktisch immer, während Query-Strings je nach Konfiguration entfallen können.

In beiden Varianten landet der Token im Browser-Verlauf und gegebenenfalls in Logs. Bei kurzlebigen Einmal-Token ist das kein Problem, der Token ist zu diesem Zeitpunkt ja bereits genutzt.
Anders sieht es aus bei den nun folgenden mehrfach nutzbaren Links.

Es gibt Situationen, in denen ein strikt einmaliger Link nicht passt. Ein typisches Beispiel sind Demo-Installationen: Interessenten sollen eine Anwendung mit Testdaten ausprobieren können, ohne dass dafür Benutzerkonten angelegt und Passwörter verteilt werden müssen. Ähnliche Anforderungen entstehen bei Preview-Umgebungen aus der CI, bei Messesystemen oder bei zeitlich begrenztem Zugang für externe Gutachter.

Spring Security sieht eine Mehrfachnutzung nicht direkt vor. Der Erweiterungspunkt dafür ist das Interface OneTimeTokenService, mit dem sich durch eine eigene Implementierung die Standardimplementierung ersetzt lässt. Der wesentliche Unterschied befindet sich in der consume Methode: In dieser Variante wird der Token geprüft, aber nicht entfernt. Er bleibt damit bis zum Ablaufzeitpunkt beliebig oft verwendbar.

Token-Service für mehrfach nutzbare Links
public class ReusableTokenService implements OneTimeTokenService
{
    private final Map<String, OneTimeToken> tokens = new ConcurrentHashMap<>();
    private final Clock clock = Clock.systemUTC();
    private final Duration validity;

    public ReusableTokenService(Duration validity)
    {
        this.validity = validity;
    }

    @Override
    public OneTimeToken generate(GenerateOneTimeTokenRequest request)
    {
        final var token = new DefaultOneTimeToken(
                UUID.randomUUID().toString(),
                request.getUsername(),
                this.clock.instant().plus(this.validity));

        this.tokens.put(token.getTokenValue(), token);
        return token;
    }

    @Override
    public OneTimeToken consume(OneTimeTokenAuthenticationToken authenticationToken)
    {
        final var token = this.tokens.get(authenticationToken.getTokenValue());
        if (token == null || this.clock.instant().isAfter(token.getExpiresAt()))
        {
            return null;
        }

        // Kein remove()
        return token;
    }
}

Die Registrierung erfolgt wie bei Spring gewohnt als Bean. Im Beispielprojekt hängt dies am Spring-Profil demo-link. Über die Gültigkeitsdauer lässt sich vom befristeten Zugang bis zu einem dauerhaften Link alles abbilden.

Aktivierung der Bean über Profil
@Configuration
@Profile("demo-link")
public class ReusableTokenConfig
{
    @Bean
    public OneTimeTokenService oneTimeTokenService()
    {
        return new ReusableTokenService(Duration.ofDays(30));
    }
}

Anmeldung ohne Zwischenformular

Bei den bisher eingeführten Varianten muss nach dem Aufruf des Links noch ein Formular abgeschickt werden. Gerade bei einer Demo-Installation, oder wenn aus anderen Gründen ein direkter Zugriff gewährt werden soll, ist das ein unnötiger Zwischenschritt.
Das lässt sich - ohne JavaScript - mit Spring Security umsetzen, indem bereits der GET-Aufruf die Anmeldung durchführt. Diese Variante ergibt vor allem in Kombination mit den zuvor erláuterten mehrfach nutzbaren Tokens Sinn.

Spring Security bringt mit dem AuthenticationFilter einen generischen Security-Filter mit, der sich genau dafür konfigurieren lässt. Zur Extraktion des Tokes wird ein Converter verwendet. Dieser liest den Token aus dem Pfad und erzeugt daraus ein OneTimeTokenAuthenticationToken. Die Prüfung übernimmt anschließend der OneTimeTokenAuthenticationProvider, ab hier läuft also derselbe Mechanismus, wie beim Standard-Login ab.

Filter für die direkte Anmeldung per GET
public class DirectMagicLinkFilter extends AuthenticationFilter
{
    private static final RequestMatcher MATCHER =
            PathPatternRequestMatcher.pathPattern(HttpMethod.GET, "/direct/{token}");

    public DirectMagicLinkFilter(AuthenticationManager authenticationManager)
    {
        super(authenticationManager, DirectMagicLinkFilter::extractToken);

        setRequestMatcher(MATCHER);
        setSecurityContextRepository(new HttpSessionSecurityContextRepository());
        setSuccessHandler(new RedirectWithoutChain("/"));
        setFailureHandler(new SimpleUrlAuthenticationFailureHandler("/login?error"));
    }

    private static Authentication extractToken(HttpServletRequest request)
    {
        final var match = MATCHER.matcher(request);
        if (!match.isMatch())
        {
            return null;
        }

        return new OneTimeTokenAuthenticationToken(match.getVariables().get("token"));
    }
}

Zwei Details sind dabei beachtenswert.+ Der AuthenticationFilter assoziiert standardmäßig den SecurityContext nur mit dem aktuell laufenden Request, dazu wird das RequestAttributeSecurityContextRepository verwendet. Nach dem Redirect als Ergebnis der erfolgreichen Anmeldung wäre die Authentication damit wieder verloren. Abhilfe schafft hier das HttpSessionSecurityContextRepository und Verwendung einer HTTP-Session.
Zweitens setzt die Standardimplementierung von AuthenticationSuccessHandler die Filterkette nach erfolgreicher Anmeldung fort. Der Request landet dann trotz gesendetem Redirect noch im DispatcherServlet, das für /direct/{token} keinen Handler findet und eine Warnung protokolliert. Um das zu vermeiden wird RedirectWithoutChain verwendet, er beendet die Verarbeitung mit dem Redirect.

Der Filter wird in der Security-Konfiguration registriert, der Pfad /direct/* muss ohne Authentifizierung aufrufbar sein.

Registrierung des Filters
final var directLogin = new DirectMagicLinkFilter(
        new ProviderManager(new OneTimeTokenAuthenticationProvider(tokenService, users)));

http
        .authorizeHttpRequests(authorize -> authorize
                .requestMatchers("/ott/sent", "/magic/*", "/direct/*").permitAll()
                .anyRequest().authenticated())
        .oneTimeTokenLogin(Customizer.withDefaults())
        .addFilterBefore(directLogin, AuthorizationFilter.class);

Nach dem Klick auf den Link ist der Nutzer angemeldet und wird per Redirect auf der Startseite geleitet. Der genutzte Link verschwindet damit auch schnell wieder aus der Adresszeile. Der Browserverlauf hat behält ihn allerdings weiter.

Das ganze ist nicht ohne Risiko. Hier wird eine authentifizierte HTTP Session aufgebaut, ohne dass der Nutzer etwas aktiv beisteuert. So könnte ein Dritter den Link auf einer beliebigen Webseite als Bild einbinden und Besucher unbemerkt in der Anwendung anmelden. Und danach könnten im Namen des Nutzers möglicherweise noch per CSRF Aktionen ausgelöst werden.

Also: Auch wenn das technisch relativ einfach umsetzbar ist, eröffnen sich damit erhebliche neue Angriffsvektoren.

Ein mehrfach oder gar dauerhaft nutzbarer Magic Link ist faktisch ein Passwort in der URL. Das sollte man sich vor dem Einsatz bewusst machen, denn URLs sind an vielen Stellen sichtbar:

  1. Browser Verlauf

  2. Server- und Proxy-Logs

  3. Linkvorschau von Chat-Programmen

  4. Bei jeder Weiterleitung der ursprünglichen E-Mail

Der Link ist zudem in keiner Weise an Person oder Gerät gebunden. Wer ihn kennt, erhält damit den Zugriff. Ein Widerruf erfordert das serverseitige Löschen des Tokens.

Alternativen

Bevor das Sicherheitsniveau der Anwendung selbst abgesenkt wird, lohnt der Blick auf Alternativen, die die Authentifizierung an vorhandene Infrastruktur verlagern.

Ein vorgelagerter oauth2-proxy übernimmt die Authentifizierung gegen einen bestehenden Identity Provider, etwa Keycloak, GitHub oder Google. Die Anwendung selbst bleibt in dem Fall ohne eigene lokale Nutzerverwaltung. Der Zugriff lässt sich natürlich auch auf bestimmte Benutzer oder Gruppen einschränken.

Ein weiteres Mittel von Spring Security sind die Pre-Authentication Varianten. Dabei übernimmt die Anwendung eine bereits erfolgte Authentifizierung aus der vorgelagerten Infrastruktur, zum Beispiel über den RequestHeaderAuthenticationFilter aus einem durch einen Reverse Proxy gesetzten HTTP-Header oder über X.509-Clientzertifikate. Das passt immer dann, wenn ein SSO-Gateway oder Proxy die Identität ohnehin schon geprüft hat.

Beide Ansätze vermeiden langlebige Credentials in URLs und skalieren auch gut in einer umfangreicheren IT Landschaft.

Beispielprojekt

Das vollständige Projekt mit beiden Token-Varianten und allen drei Linkformen steht als demo.zip zum Download bereit. Benötigt werden Java 25 und Maven.

Start der Beispielanwendung
# Einmal-Links (Standard)
mvn spring-boot:run

# Mehrfach nutzbare Links
mvn spring-boot:run -Dspring-boot.run.profiles=demo-link

Nach der Eingabe des Benutzernamens demo unter http://localhost:8080/login stehen die Magic Links im Anwendungslog.

Mit mvn test laufen die enthaltenen Tests, die alle drei Linkvarianten für einmalig und für mehrfach nutzbare Tokens abdecken. Sie zeigen unter anderem, dass der direkte Link mit Standard-Tokens nach dem ersten Aufruf verbraucht ist.
Ein Hinweis zu Spring Boot 4 an dieser Stelle: Die MockMvc-Testunterstützung ist mit der Modularisierung in ein eigenes Modul gewandert und wird über die Abhängigkeit spring-boot-webmvc-test ergänzt.

Fazit

Mit dem One-Time-Token-Login lassen sich Magic Links in Spring Boot 4 und Spring Security 7 mit wenigen Zeilen eigener Logik umsetzen, lediglich die Zustellung des Links bleibt Aufgabe der Anwendung. Die Standardeinstellungen mit kurzlebigen Einmal-Token sind für den produktiven Einsatz eine solide Basis. Mehrfach nutzbare Links sind über einen eigenen OneTimeTokenService schnell umgesetzt, bleiben aber ein Passwort in der URL und gehören nur in Umgebungen mit bewusst niedrigem Schutzbedarf. Wo ein Identity Provider oder eine vorgelagerte Authentifizierung vorhanden ist, sind oauth2-proxy oder Pre-Authentication die robustere Lösung.

Feedback oder Fragen zu einem Artikel - per E-Mail an [email protected] oder über das Kontaktformular. Wir freuen uns auf eine Kontaktaufnahme!

Suche

Los geht's!

Bitte teilen Sie uns mit, wie wir Sie am besten erreichen können.