Implementing Passwordless Magic Link Authentication in Java

Magic Link authentication, also known as "Passwordless Login," is a user-friendly authentication method that eliminates the need for users to remember passwords. Instead, users simply enter their email address, and the system sends them a unique, time-limited URL that logs them in automatically when clicked.

This approach enhances user experience, reduces password-related support tickets, and improves security by removing phishing-prone passwords.


How Magic Link Authentication Works

  1. Initiation: User enters their email on a login form.
  2. Token Generation: The backend generates a cryptographically secure, unique token and associates it with the user's email and an expiration timestamp.
  3. Email Dispatch: The system sends an email containing a login link with the token (e.g., https://yourapp.com/auth/verify?token=abc123...).
  4. Verification: User clicks the link. The backend validates the token, checks its expiration, and authenticates the user.
  5. Session Creation: A traditional session or JWT is established upon successful verification.

Java Implementation Guide

We'll build a simple Magic Link service using Spring Boot with a clean, layered architecture.

1. Project Dependencies (pom.xml)

<dependencies>
<!-- Spring Boot Starter Web -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
</dependency>
<!-- Spring Data JPA for persistence -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-jpa</artifactId>
</dependency>
<!-- H2 Database (for demo) -->
<dependency>
<groupId>com.h2database</groupId>
<artifactId>h2</artifactId>
<scope>runtime</scope>
</dependency>
<!-- Java Mail Sender -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<!-- Validation -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-validation</artifactId>
</dependency>
</dependencies>

2. Data Model: MagicToken Entity

@Entity
@Table(name = "magic_tokens")
public class MagicToken {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
@Column(nullable = false, unique = true)
private String token;
@Column(nullable = false)
private String email;
@Column(nullable = false)
private Instant expiryDate;
@Column(nullable = false)
private boolean used = false;
// Constructors, Getters, and Setters
public MagicToken() {}
public MagicToken(String token, String email, Instant expiryDate) {
this.token = token;
this.email = email;
this.expiryDate = expiryDate;
}
public boolean isExpired() {
return Instant.now().isAfter(expiryDate);
}
// Standard getters and setters...
}

3. Repository Layer

@Repository
public interface MagicTokenRepository extends JpaRepository<MagicToken, Long> {
Optional<MagicToken> findByTokenAndUsedFalse(String token);
void deleteByExpiryDateBefore(Instant expiryDate);
// Prevent multiple active tokens for the same email
@Modifying
@Query("DELETE FROM MagicToken mt WHERE mt.email = :email AND mt.used = false")
void deleteExistingTokensForEmail(@Param("email") String email);
}

4. Service Layer

The service handles token generation, email sending, and verification logic.

@Service
public class MagicLinkService {
private final MagicTokenRepository tokenRepository;
private final JavaMailSender mailSender;
// Configurable values (should be in application.properties)
@Value("${app.magic-link.base-url:http://localhost:8080}")
private String baseUrl;
@Value("${app.magic-link.expiry-minutes:15}")
private int expiryMinutes;
public MagicLinkService(MagicTokenRepository tokenRepository, JavaMailSender mailSender) {
this.tokenRepository = tokenRepository;
this.mailSender = mailSender;
}
public void initiateMagicLinkLogin(String email) {
// 1. Clean up existing tokens for this email
tokenRepository.deleteExistingTokensForEmail(email);
// 2. Generate secure token
String token = generateSecureToken();
// 3. Calculate expiry date
Instant expiryDate = Instant.now().plus(expiryMinutes, ChronoUnit.MINUTES);
// 4. Save to database
MagicToken magicToken = new MagicToken(token, email, expiryDate);
tokenRepository.save(magicToken);
// 5. Send email
sendMagicLinkEmail(email, token);
}
public String verifyToken(String token) {
Optional<MagicToken> magicTokenOpt = tokenRepository.findByTokenAndUsedFalse(token);
if (magicTokenOpt.isEmpty()) {
throw new RuntimeException("Invalid or expired magic link.");
}
MagicToken magicToken = magicTokenOpt.get();
if (magicToken.isExpired()) {
tokenRepository.delete(magicToken);
throw new RuntimeException("Magic link has expired.");
}
// Mark token as used
magicToken.setUsed(true);
tokenRepository.save(magicToken);
return magicToken.getEmail();
}
private String generateSecureToken() {
// Generate a cryptographically secure random token
SecureRandom random = new SecureRandom();
byte[] bytes = new byte[32]; // 256 bits
random.nextBytes(bytes);
// Encode to hexadecimal string
return HexFormat.of().formatHex(bytes);
}
private void sendMagicLinkEmail(String email, String token) {
try {
String magicLink = baseUrl + "/auth/verify?token=" + token;
SimpleMailMessage message = new SimpleMailMessage();
message.setTo(email);
message.setSubject("Your Magic Login Link");
message.setText("Click the link below to log in:\n\n" + magicLink + 
"\n\nThis link will expire in " + expiryMinutes + " minutes.");
mailSender.send(message);
} catch (Exception e) {
throw new RuntimeException("Failed to send magic link email", e);
}
}
// Clean up expired tokens (can be called by a scheduled task)
@Scheduled(cron = "0 0 * * * *") // Run every hour
public void cleanupExpiredTokens() {
tokenRepository.deleteByExpiryDateBefore(Instant.now());
}
}

5. REST Controller

@RestController
@RequestMapping("/auth")
public class AuthController {
private final MagicLinkService magicLinkService;
public AuthController(MagicLinkService magicLinkService) {
this.magicLinkService = magicLinkService;
}
@PostMapping("/magic-link")
public ResponseEntity<?> requestMagicLink(@Valid @RequestBody MagicLinkRequest request) {
try {
magicLinkService.initiateMagicLinkLogin(request.getEmail());
return ResponseEntity.ok().body(
Map.of("message", "Magic link sent to your email!")
);
} catch (Exception e) {
return ResponseEntity.badRequest().body(
Map.of("error", "Failed to send magic link: " + e.getMessage())
);
}
}
@GetMapping("/verify")
public ResponseEntity<?> verifyMagicLink(@RequestParam String token) {
try {
String email = magicLinkService.verifyToken(token);
// Here you would typically:
// 1. Create a session or JWT
// 2. Set authentication in SecurityContext
// 3. Redirect to dashboard or return success response
return ResponseEntity.ok().body(
Map.of("message", "Login successful!", "email", email)
);
} catch (RuntimeException e) {
return ResponseEntity.badRequest().body(
Map.of("error", e.getMessage())
);
}
}
}
// DTO for request
class MagicLinkRequest {
@Email
@NotBlank
private String email;
// Getters and Setters
public String getEmail() { return email; }
public void setEmail(String email) { this.email = email; }
}

6. Configuration (application.properties)

# Database
spring.datasource.url=jdbc:h2:mem:testdb
spring.jpa.hibernate.ddl-auto=create-drop
# Email Configuration (example for Gmail)
spring.mail.host=smtp.gmail.com
spring.mail.port=587
[email protected]
spring.mail.password=your-app-password
spring.mail.properties.mail.smtp.auth=true
spring.mail.properties.mail.smtp.starttls.enable=true
# Magic Link Configuration
app.magic-link.base-url=http://localhost:8080
app.magic-link.expiry-minutes=15

Security Considerations & Best Practices

  1. Cryptographically Secure Tokens: Always use SecureRandom for token generation to prevent predictability.
  2. Short Expiration Time: 10-15 minutes is ideal to minimize the window of opportunity if a link is intercepted.
  3. Single-Use Tokens: Mark tokens as used immediately after verification to prevent replay attacks.
  4. Secure Email Transport: Ensure emails are sent over secure channels (TLS). Consider including device/browser information in the email for user awareness.
  5. Rate Limiting: Implement rate limiting on the magic link request endpoint to prevent email bombing.
  6. Token Cleanup: Regularly clean up expired tokens from the database.
  7. Frontend Integration: The verification endpoint should ideally redirect to a success/failure page in a Single Page Application (SPA).

Advanced Enhancements

  • JWT Integration: Instead of just returning success, issue a JWT upon magic link verification.
  • Custom HTML Emails: Use Thymeleaf or Freemarker templates to create beautiful, branded emails.
  • Audit Logging: Log magic link requests and verifications for security monitoring.
  • IP Address Tracking: Associate tokens with the IP address that requested them for additional security.

This implementation provides a solid foundation for passwordless authentication that you can extend based on your specific security and user experience requirements.

Leave a Reply

Your email address will not be published. Required fields are marked *


Macro Nepal Helper