Authentication
Oct 6, 2025

Spring Boot 3 passwordless authentication: OTP and magic link with Scalekit

Srinivas Karre
Founding Engineer

TL;DR

  • Passwordless authentication in Spring Boot 3 is implemented by combining Scalekit’s SDK with a central AuthService, which manages state-to-auth_request_id mappings and verifies OTPs or magic links.
  • A hybrid design blends traditional MVC for human-facing flows with reactive WebFlux endpoints for SPAs and mobile apps, ensuring scalability by wrapping blocking SDK calls in boundedElastic threads.
  • Security is enforced through Spring Security sessions for MVC users and stateless REST endpoints that can be extended with JWTs and role-based access control, making the system adaptable to enterprise needs.
  • Resilience is built in with graceful handling of expired tokens, retry limits, same-origin checks, and transient network failures, all logged cleanly at DEBUG or WARN without noisy stack traces.
  • The application is production-ready with secrets managed via environment variables, minimal JPA/H2 persistence for development, Redis support for distributed deployments, and GraalVM native builds for faster startup and lower resource use.

Introduction: The hidden costs and complexity of passwords in enterprise Java apps

Picture yourself leading the backend team for a rapidly scaling enterprise SaaS platform built on Spring Boot 3. Your product serves thousands of users across multiple organizations, each with distinct security policies. Some require SOC 2 or ISO 27001 compliance, while others mandate periodic password resets or multi-step recovery procedures. These variations make authentication not just a feature, but a constant compliance and engineering burden.

Every day, support tickets pile up: users forget passwords, mistype recovery codes, or get locked out mid-task. Even with enterprise SSO and OAuth flows, onboarding a new user often means juggling emails, temporary passwords, and repeated follow-ups. Each incident erodes productivity, frustrates users, and consumes valuable engineering cycles.

Traditional password-based systems also introduce subtle but significant risks:

  • Passwords stored or transmitted in email clients and browser caches expose new attack surfaces.
  • Engineers spend time maintaining recovery flows and MFA patches instead of building features.
  • UX suffers as SPAs, mobile apps, and APIs each require slightly different login workarounds.
  • Logging, monitoring, and session management grow increasingly complex as services scale.

These challenges are not theoretical; they are daily realities for developers working on enterprise-grade Spring Boot applications. In this guide, we’ll show how moving to a passwordless model addresses these issues while simplifying architecture and improving security.

How headless passwordless authentication addresses these challenges

Headless passwordless authentication, powered by Scalekit’s Java SDK, eliminates passwords. Users authenticate via one-time passcodes (OTP) or magic links sent to their email. This approach provides:

  • Frictionless UX: Users log in with a single OTP or a one-click magic link.
  • Security by design: Short-lived tokens reduce the attack surface; optional same-browser enforcement prevents replay attacks.
  • Developer-friendly integration: Spring Boot + Scalekit SDK structures flows with clean logging, error handling, and session management.
  • Consistency across clients: Identical backend flows for MVC pages, SPAs, mobile apps, and REST APIs.
  • Extensibility: Easily add JWT issuance, persistent session stores, or audit logging.

Both OTP and magic link flows exist to fit different user preferences and client types, with manual entry for OTP or seamless one-click login for magic links. The rest of this guide will dive into how these flows work in practice, along with edge-case handling and production patterns.

A headless passwordless approach with Scalekit

Authentication in modern Java applications is rarely a single endpoint or a simple login form. In a typical Spring Boot 3 SaaS app, developers must balance multiple flows: web MVC for interactive users, reactive REST APIs for SPAs and mobile apps, session management, and ephemeral token lifecycles. Add enterprise requirements like origin enforcement, rate limiting, and multi-tenant policies, and suddenly a straightforward login becomes a labyrinth of edge cases and fragile glue code.

Our approach leverages headless passwordless authentication to simplify this landscape. Instead of passwords, each login flow is a single ephemeral interaction: either a magic link the user clicks or a one-time passcode they enter. This removes password storage and reset logic entirely. On the backend, we maintain a lightweight state mapping between auth requests and transient tokens, enabling us to verify OTPs or magic links securely while keeping the implementation straightforward.

Passwordless authentication screen

To make the above work across different clients, we adopt a hybrid architecture:

  • MVC (Thymeleaf): Handles human-facing flows, login forms, OTP entry pages, and magic link callbacks.
  • Reactive REST endpoints (WebFlux): Provides SPA or mobile clients with non-blocking access to authentication APIs.
  • Session-based security (Spring Security): Establishes authentication post-verification for MVC flows, with optional extension points for JWT-based stateless sessions.
  • Lightweight persistence (JPA + H2): Stores minimal user information, enough to track successful logins and display names.

Together, these patterns solve the common pain points of session synchronization, ephemeral token handling, error propagation, and clean logging, without overwhelming developers with boilerplate or unnecessary complexity.

Architecture and core components

In a Spring Boot 3 application, implementing passwordless authentication means coordinating multiple concerns: user-facing pages for human login, reactive endpoints for SPAs or mobile clients, secure session handling, and transient token lifecycles. Without a clear separation of responsibilities, this quickly becomes brittle and difficult to debug. Our sample app addresses this by layering responsibilities: AuthService, MVC controllers, reactive REST endpoints, Spring Security session management, and persistence.

At the center is AuthService, which encapsulates all interaction with the Scalekit SDK. It initializes the SDK with environment properties (scalekit.environment_url, client_id, client_secret), generates correlation states, constructs magic link callbacks, and maps states to auth_request_ids. This mapping is critical: it allows OTPs or magic links to be verified securely while keeping controllers simple. Errors like expired codes, invalid links, or gRPC timeouts are caught here, with concise logging that makes debugging straightforward.

MVC controllers are responsible only for user-facing flows. A user submits their email via a login form, the controller calls AuthService to send an OTP or magic link, and the resulting auth_state is stored in the session. Verification endpoints update the session upon success, and failed attempts are returned with clear feedback. Example:

@PostMapping("/login") public RedirectView login(@RequestParam String email, HttpSession session) { String state = authService.sendPasswordlessLinkOrOtp(email); session.setAttribute("auth_state", state); return new RedirectView("/auth/verify"); }

Reactive REST endpoints (WebFlux) serve SPAs and mobile apps. Since Scalekit’s SDK is blocking, calls are wrapped in Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()), ensuring the reactive pipeline stays non-blocking under load. These endpoints return JSON responses instead of mutating sessions, making them ideal for stateless token issuance (e.g., JWTs) in production.

Persistence plays a supporting role. The sample app uses a lightweight JPA/H2 setup to store only what is essential: user email and display name. This avoids overengineering while still allowing login tracking and user recognition. For real deployments, the same pattern can scale to PostgreSQL/MySQL with Spring profiles, or be extended to include richer user metadata. Keeping persistence lean in the sample app keeps the focus on auth flows, while still providing a realistic foundation developers can build on.

By structuring the app this way, each component has a clear single responsibility:

  • AuthService → SDK abstraction and verification logic
  • Controllers → HTTP request handling
  • Reactive endpoints → scalable, non-blocking APIs
  • Persistence → durable but minimal user state

This separation makes the system easier to reason about, test, and extend without turning authentication into a maintenance burden.

Authentication flows and edge cases

Even with a solid architecture, authentication flows can be fragile in practice. Users may mistype an OTP, click a magic link from a different device, or hit transient network issues. Designing for these cases is what makes the difference between a demo and a production-ready system.

Screen to enter otp or code

OTP flow

The OTP flow is designed to be simple for end users but requires careful orchestration on the backend.

OTP flow

This sequence shows how the backend generates, delivers, and validates OTPs against a transient state.

Initiation:

  1. The user provides an email on /auth/login or through a REST request at /api/auth/send.
  2. AuthService creates a correlation state and asks Scalekit to send a one-time passcode to that email.
  3. The state-to-auth_request_id mapping is stored temporarily in memory (or in Redis for distributed setups).

Verification:

  1. The user submits the OTP on /auth/verify or via /api/auth/verify/otp.
  2. AuthService validates the OTP against Scalekit using the stored auth_request_id.
  3. If successful, MVC flows attach authentication to the session, while REST APIs return a JSON success response.
@PostMapping("/verify") public String verifyOtp(@RequestParam String code, @RequestParam String state, HttpSession session) { String authRequestId = (String) session.getAttribute("auth_state"); boolean success = authService.verifyCodeOrLink(code, authRequestId); return success ? "Login Successful" : "Invalid or Expired OTP"; }

Edge cases and handling:

  • Expired OTPs do not crash the flow. Instead, the user is shown a retry option with clear messaging.
  • If the maximum number of attempts is exceeded, the user is prompted to restart the login process.
  • Transient network issues, such as gRPC timeouts, are caught in AuthService and logged at DEBUG/WARN, preserving visibility for developers without overwhelming production logs.

This design ensures that the OTP flow is predictable for users, maintainable for developers, and resilient to the kinds of issues that occur in real deployments.

Explore the difference between OTP and magic link here.

Magic link flow

Magic links provide a near-frictionless login experience, but they also require additional safeguards to ensure security and consistency.

This sequence illustrates how the server issues a link with embedded state and validates it on callback.

Initiation:

  1. AuthService generates a unique state parameter and builds a magic link pointing to /auth/callback.
  2. Scalekit sends this link to the user’s email.
  3. The state-to-auth_request_id mapping is stored for later verification, ensuring that only the intended request can be validated.

Callback handling:

  1. The user clicks the magic link and lands on /auth/callback.
  2. If the token is included in a URL fragment (common in SPAs), an intermediate page extracts it and posts it back to /auth/callback/verify.
  3. AuthService retrieves the auth_request_id from the stored state and verifies the link with Scalekit.
@PostMapping("/callback/verify") public String verifyMagicLink(@RequestParam String linkToken, @RequestParam String state, HttpSession session) { String authRequestId = stateMap.get(state); String email = authService.verifyMagicLink(linkToken, authRequestId); if(email != null) { // set session auth session.setAttribute("user_email", email); return "Magic Link Verified Successfully"; } return "Invalid or Expired Link"; }

Edge cases and handling:

  • If auth_request_id is missing, the system falls back to the stored state map to recover it.
  • Expired or already-used links are rejected gracefully with a clear error message, prompting the user to request a new link.
  • Same-browser origin enforcement can be enabled to prevent phishing and replay attacks across different browsers or devices.

Why this flow works:

By isolating initiation, verification, and session handling, the system creates a login experience that is both smooth for end users and predictable for developers. Controllers remain lightweight, AuthService centralizes SDK interactions and state tracking, and the architecture naturally supports stateless REST APIs or JWT-based extensions. The result is a secure, extensible, and user-friendly login flow.

Explore more about passwordless authentication methods here ->

Implementation patterns and detailed logic

Once the overall architecture is in place, the challenge shifts to executing authentication flows in a way that is reliable, maintainable, and production-ready. The system must shield developers from low-level SDK details, while still making each step of the flow explicit and debuggable. To achieve this, the implementation is organized into five core areas: service abstraction, reactive wrapping, security integration, persistence, and edge-case handling.

1. AuthService abstraction

The AuthService is the backbone of passwordless authentication. It centralizes all Scalekit SDK interactions, so controllers and endpoints remain thin. Its responsibilities include sending OTPs or magic links, managing the state-to-auth_request_id mapping, and verifying responses.

public String sendPasswordlessLinkOrOtp(String email) { String state = UUID.randomUUID().toString(); String callbackUrl = magiclinkUri + "?state=" + state; PasswordlessOptions options = new PasswordlessOptions(); options.setTemplate(TemplateType.SIGNIN); options.setMagiclinkAuthUri(callbackUrl); PasswordlessResponse response = client.passwordless() .sendPasswordlessEmail(email, options); stateMap.put(state, response.getAuthRequestId()); return state; }

Key points:

  • The in-memory stateMap keeps flows simple during development, but production deployments should use Redis or another distributed cache.
  • Failures such as expired codes are logged at WARN level with minimal detail, while stack traces are avoided to keep logs clean.
  • Both OTP and magic links are dispatched through the same interface, ensuring consistency.

2. Reactive REST wrapping

Because Scalekit’s Java SDK uses blocking calls, integrating it directly with Spring WebFlux would risk blocking event loops. To prevent this, SDK calls are wrapped in Reactor’s Mono.fromCallable(...) and executed on bounded elastic threads.

@PostMapping("/api/auth/send") public Mono<ApiResponse> sendOtp(@RequestBody SendRequest request) { return Mono.fromCallable(() -> { String state = authService.sendPasswordlessLinkOrOtp(request.getEmail()); return new ApiResponse(true, "Sent successfully", Map.of("authRequestId", state)); }).subscribeOn(Schedulers.boundedElastic()); }

This approach allows SPA and mobile clients to interact with the API in a fully non-blocking way, while still relying on synchronous SDK methods under the hood.

3. Spring security integration

Once verification succeeds, the system must establish identity in a way that Spring Security understands. For MVC flows, a UsernamePasswordAuthenticationToken is placed into the SecurityContextHolder, which automatically authenticates the user’s session.

UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(email, null, List.of(new SimpleGrantedAuthority("ROLE_USER"))); SecurityContextHolder.getContext().setAuthentication(auth);

Reactive REST endpoints remain stateless by design. They return verification results directly to the client, leaving room to later issue JWTs or API tokens when scaling to distributed or mobile-first architectures.

4. Persistence layer

To ground authentication in something durable, the application includes a simple persistence model:

@Entity public class UserAccount { @Id @GeneratedValue private Long id; private String email; private String displayName; }

This is backed by an H2 in-memory database for local development, which accelerates testing and iteration. In production, the same repository interface can be backed by PostgreSQL or MySQL using Spring profiles. The persistence layer is deliberately minimal: it tracks email and display name, enough to confirm identity and surface a user-friendly login experience. Applications that require roles, policies, or audit trails can extend this model without touching the authentication flows.

5. Handling edge cases gracefully

Real-world authentication never follows the happy path. OTPs expire, users retry with invalid codes, and network calls fail under load. These scenarios are not treated as exceptions but as expected outcomes.

public boolean verifyCodeOrLink(String code, String authRequestId) { try { return client.passwordless() .verifyPasswordlessEmail(code, authRequestId) .isSuccess(); } catch (ApiException e) { log.warn("Verification failed for authRequestId {}: {}", authRequestId, e.getMessage()); return false; } }

The system responds to expired tokens with clear retry prompts, enforces attempt limits to stop brute force, and logs network issues at WARN without interrupting the user flow. This ensures that developers can debug issues without drowning in noise, while users receive predictable feedback.

Takeaways

  • Single responsibility: Controllers handle HTTP, AuthService handles SDK, persistence holds minimal data.
  • Reactive compatibility: Blocking SDK calls coexist safely with WebFlux through bounded elastic threads.
  • Security alignment: Spring Security integrates sessions for MVC flows, with REST endpoints open for JWT-based extensions.
  • Production readiness: Redis for distributed state, PostgreSQL for persistence, and consistent edge-case handling ensure smooth scaling.

Testing strategies

Even with a clean architecture, robust testing ensures confidence in the passwordless flows, especially when dealing with ephemeral OTPs, magic links, and network-dependent SDK calls. Our approach focuses on unit, integration, and reactive endpoint testing without overcomplicating the codebase.

Unit testing AuthService

  • Mock the ScalekitClient to simulate OTP/magic link responses.
  • Verify:
    • sendPasswordlessLinkOrOtp generates a state and stores the correct auth_request_id.
    • verifyCodeOrLink correctly validates OTP success/failure scenarios.
    • verifyMagicLink handles missing or expired tokens gracefully.

Example (using Mockito):

@Test void testSendPasswordlessLinkOrOtp() throws Exception { ScalekitClient mockClient = mock(ScalekitClient.class); when(mockClient.passwordless().sendPasswordlessEmail(any(), any())) .thenReturn(new PasswordlessResponse("auth_req_123")); AuthService authService = new AuthService(mockClient, magiclinkUri); String state = authService.sendPasswordlessLinkOrOtp("user@example.com"); assertNotNull(state); assertEquals("auth_req_123", authService.getAuthRequestId(state)); }

Integration testing MVC controllers

  • Use MockMvc to simulate user flows:
    • /auth/login triggers OTP or magic link sending.
    • /auth/verify successfully authenticates a user.
    • /auth/callback/ verify correctly processes magic link fragments.

Reactive REST endpoint testing

  • Use WebTestClient for non-blocking REST API tests:
    • /api/auth/send returns the correct authRequestId.
    • /api/auth/verify/otp and /verify/magic handle success/failure as expected.
  • Wrap blocking SDK calls inside Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()) to verify thread-safety and non-blocking behavior in tests.

Why it works:

This strategy ensures that both human-facing and programmatic flows are verified end-to-end without requiring actual email delivery or external network calls, making CI/CD reliable and fast.

GraalVM / Native build guidance

For production environments or cloud-native deployments, this application can be compiled into a native executable using GraalVM, reducing startup time and memory footprint. This is especially useful for high-concurrency authentication flows and serverless deployments.

Requirements

  • GraalVM JDK 21+ installed
  • native-image component installed
  • gu install native-image
  • Maven build tool installed (with your project configured).

Maven plugin configuration

Add the following plugin to your pom.xml to enable native image compilation:

<build> <plugins> <plugin> <groupId>org.graalvm.buildtools</groupId> <artifactId>native-maven-plugin</artifactId> <version>0.9.22</version> <executions> <execution> <goals> <goal>native-image</goal> </goals> </execution> </executions> </plugin> </plugins> </build>

Sample shell script

Create a script called run-native.sh to build and run the native binary with environment variables:

#!/bin/bash # Load environment variables export SCALEKIT_CLIENT_ID=your-client-id export SCALEKIT_CLIENT_SECRET=your-client-secret export SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.dev export SCALEKIT_MAGICLINK_AUTH_URI=http://localhost:8080/auth/callback # Build native image mvn -DskipTests clean package native:compile # Run native executable ./target/passwordless-auth

Make the script executable:

chmod +x run-native.sh

Running the Native Build

Execute the script:

./run-native.sh

This will:

  1. Load your Scalekit credentials from environment variables.
  2. Compile the Spring Boot app into a native executable.
  3. Run the application with all passwordless flows (OTP + Magic Link) functional.

Additional notes

When building the native image, some gRPC or protobuf calls may require additional reachability configuration under META-INF/native-image. This ensures that reflection and serialization work correctly during runtime.

Although the Scalekit SDK is blocking, all SDK calls in this project are wrapped using Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()). This integration allows the application to remain non-blocking for reactive WebFlux endpoints while still depending on synchronous SDK methods.

Application secrets, including the Scalekit client ID and secret, should always be provided through environment variables or .env files. Credentials must never be hardcoded into the source code or configuration files checked into version control.

Running the application as a GraalVM native image brings significant production benefits. Startup times are nearly instant, memory usage is lower compared to running on the JVM, and performance under high concurrency becomes more predictable. These characteristics make the setup well-suited for cloud-native deployments, serverless environments, and horizontally scaled systems.

Conclusion

In this guide, we explored how Spring Boot 3 applications can leverage Scalekit’s headless passwordless authentication to deliver secure, frictionless login experiences. We examined the challenges of traditional password-based systems, forgotten credentials, complex recovery flows, and inconsistent UX across SPAs, mobile apps, and enterprise clients.

We demonstrated how OTP and magic link flows, powered by the Scalekit Java SDK, address these pain points. By structuring authentication around AuthService, hybrid MVC and WebFlux endpoints, minimal persistence, and robust session management, developers can implement passwordless flows that are both secure and maintainable.

We also highlighted how edge cases, such as expired tokens, missing state, and transient network failures, are handled gracefully, ensuring a consistent user experience and a clean, debuggable workflow for developers.

For readers looking to take the next step, the sample project provides a ready-to-run environment to experiment with OTP and magic link flows, including edge cases and reactive REST endpoints. Exploring Scalekit’s documentation, Java SDK reference, and native GraalVM builds will enable you to extend the app for production scenarios, integrate JWT-based stateless sessions, and adopt distributed state management with Redis.

By applying these patterns, you can modernize your Spring Boot authentication infrastructure, reduce friction for users, and maintain a secure, scalable system. Start experimenting today with the demo, dive into Scalekit’s guides, and consider integrating passwordless authentication into your own applications to experience the benefits firsthand.

FAQ

How does Scalekit handle OTP expiration and retry limits?

Scalekit enforces time-bound OTP lifecycles and tracks the maximum number of verification attempts per auth_request_id. Expired OTPs or exceeded attempts return predictable HTTP 429 responses. This ensures secure passwordless flows while allowing backend services to implement graceful retry and logging strategies.

Can Scalekit magic links be verified across multiple browsers or devices?

Yes, but for enhanced security, Scalekit supports same-origin enforcement. Magic links include a state parameter mapped to an auth_request_id in the backend, preventing cross-browser or cross-device token reuse while maintaining stateless API verification.

How does Scalekit integrate with reactive Spring WebFlux endpoints?

Scalekit’s blocking Java SDK can be wrapped in Mono.fromCallable(...).subscribeOn(Schedulers.boundedElastic()), enabling non-blocking reactive flows. This preserves high-concurrency SPA or mobile client interactions while leveraging server-side verification, ephemeral token management, and audit logging.

What are best practices for handling ephemeral token state in distributed Spring Boot deployments?

Use an external distributed cache (e.g., Redis) to persist state → auth_request_id mappings. This ensures horizontal scalability, consistent OTP/magic link verification across multiple instances, and avoids relying on in-memory state, which is unsuitable for cloud-native deployments.

How should blocking SDK calls be integrated with Spring Security session management in a reactive application?

Wrap blocking calls with boundedElastic schedulers, then update SecurityContextHolder post-verification for MVC flows. For REST endpoints, maintain stateless JWT issuance, separating authentication logic from session mutation, ensuring thread-safe, reactive-compatible passwordless authentication.

Implement passwordless authentication right away. Start now ->
No items found.
On this page
Share this article
Modernize login flows

Acquire enterprise customers with zero upfront cost

Every feature unlocked. No hidden fees.
Start Free
$0
/ month
1 million Monthly Active Users
100 Monthly Active Organizations
1 SSO and SCIM connection each
20K Tool Calls
10K Connected Accounts
Unlimited Dev & Prod environments