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
- Initiation: User enters their email on a login form.
- Token Generation: The backend generates a cryptographically secure, unique token and associates it with the user's email and an expiration timestamp.
- Email Dispatch: The system sends an email containing a login link with the token (e.g.,
https://yourapp.com/auth/verify?token=abc123...). - Verification: User clicks the link. The backend validates the token, checks its expiration, and authenticates the user.
- 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
- Cryptographically Secure Tokens: Always use
SecureRandomfor token generation to prevent predictability. - Short Expiration Time: 10-15 minutes is ideal to minimize the window of opportunity if a link is intercepted.
- Single-Use Tokens: Mark tokens as used immediately after verification to prevent replay attacks.
- Secure Email Transport: Ensure emails are sent over secure channels (TLS). Consider including device/browser information in the email for user awareness.
- Rate Limiting: Implement rate limiting on the magic link request endpoint to prevent email bombing.
- Token Cleanup: Regularly clean up expired tokens from the database.
- 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.