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.