Step‑by‑Step Guide to Implement SMS Captcha Login with Spring Security
This tutorial walks you through building a complete SMS‑based captcha authentication flow in Spring Security, covering cache lifecycle, service implementation, custom authentication token, provider, filter, and configuration, and shows how to integrate it without affecting existing login methods.
1. Introduction
After two previous articles on Spring Security (UsernamePasswordAuthenticationFilter and AuthenticationManager), many readers asked how those components can be used in practice. This guide demonstrates a zero‑to‑one implementation of SMS captcha login integrated into the Spring Security framework.
Of course you can adapt the example to email or other verification channels.
2. Captcha lifecycle
Captchas are valid for a limited period, typically 5 minutes. The usual flow is: the user requests a code, the server caches it, and the code can be used only once within its validity window; after that it expires.
Captcha cache interface:
public interface CaptchaCacheStorage {
/**
* Store a captcha in the cache.
* @param phone the phone number
* @return the generated code
*/
String put(String phone);
/**
* Retrieve a captcha from the cache.
* @param phone the phone number
* @return the cached code
*/
String get(String phone);
/**
* Manually expire a captcha.
* @param phone the phone number
*/
void expire(String phone);
}Typical implementation using Spring Cache (Redis, Ehcache, Memcached, etc.):
private static final String SMS_CAPTCHA_CACHE = "captcha";
@Bean
CaptchaCacheStorage captchaCacheStorage() {
return new CaptchaCacheStorage() {
@CachePut(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
@Override
public String put(String phone) {
return RandomUtil.randomNumbers(5);
}
@Cacheable(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
@Override
public String get(String phone) {
return null;
}
@CacheEvict(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
@Override
public void expire(String phone) {
// no‑op
}
};
}Ensure the cache is reliable, as it directly impacts user experience.
The core captcha service provides two functions: sending a captcha and verifying it.
public interface CaptchaService {
/** Send a captcha to the given phone number. */
boolean sendCaptcha(String phone);
/** Verify the supplied code against the cached one. */
boolean verifyCaptcha(String phone, String code);
}
@Bean
CaptchaService captchaService(CaptchaCacheStorage captchaCacheStorage) {
return new CaptchaService() {
@Override
public boolean sendCaptcha(String phone) {
String existed = captchaCacheStorage.get(phone);
if (StringUtils.hasText(existed)) {
log.warn("captcha code [{}] is still valid", existed);
return false; // reuse existing code
}
String code = captchaCacheStorage.put(phone);
log.info("captcha: {}", code);
// TODO: invoke third‑party SMS provider here
return true;
}
@Override
public boolean verifyCaptcha(String phone, String code) {
String cached = captchaCacheStorage.get(phone);
if (Objects.equals(cached, code)) {
captchaCacheStorage.expire(phone);
return true;
}
return false;
}
};
}Controller exposing the sending endpoint:
@RestController
@RequestMapping("/captcha")
public class CaptchaController {
@Resource
CaptchaService captchaService;
@GetMapping("/{phone}")
public Rest<?> captchaByMobile(@PathVariable String phone) {
// TODO: validate phone format
if (captchaService.sendCaptcha(phone)) {
return RestBody.ok("验证码发送成功");
}
return RestBody.failure(-999, "验证码发送失败");
}
}3. Integrate into Spring Security
We need a custom Servlet Filter that intercepts the login request, extracts parameters, builds an Authentication token, and forwards it to the AuthenticationManager.
Intercept the SMS login endpoint.
Obtain login parameters and wrap them in an Authentication credential.
Delegate authentication to an AuthenticationManager.
3.1 Captcha Authentication Token
package cn.felord.spring.security.captcha;
import org.springframework.security.authentication.AbstractAuthenticationToken;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.SpringSecurityCoreVersion;
import java.util.Collection;
/** Captcha authentication credential. */
public class CaptchaAuthenticationToken extends AbstractAuthenticationToken {
private static final long serialVersionUID = SpringSecurityCoreVersion.SERIAL_VERSION_UID;
private final Object principal;
private String captcha;
/** Constructor for an unauthenticated token. */
public CaptchaAuthenticationToken(Object principal, String captcha) {
super(null);
this.principal = principal;
this.captcha = captcha;
setAuthenticated(false);
}
/** Constructor for an authenticated token. */
public CaptchaAuthenticationToken(Object principal, String captcha,
Collection<? extends GrantedAuthority> authorities) {
super(authorities);
this.principal = principal;
this.captcha = captcha;
super.setAuthenticated(true);
}
@Override
public Object getCredentials() { return this.captcha; }
@Override
public Object getPrincipal() { return this.principal; }
@Override
public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException {
if (isAuthenticated) {
throw new IllegalArgumentException("Cannot set this token to trusted - use constructor with authorities");
}
super.setAuthenticated(false);
}
@Override
public void eraseCredentials() {
super.eraseCredentials();
captcha = null;
}
}3.2 Captcha Authentication Provider
package cn.felord.spring.security.captcha;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.InitializingBean;
import org.springframework.context.MessageSource;
import org.springframework.context.MessageSourceAware;
import org.springframework.context.support.MessageSourceAccessor;
import org.springframework.security.authentication.AuthenticationProvider;
import org.springframework.security.authentication.BadCredentialsException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper;
import org.springframework.security.core.authority.mapping.NullAuthoritiesMapper;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.SpringSecurityMessageSource;
import org.springframework.util.Assert;
import java.util.Collection;
import java.util.Objects;
/** Captcha authentication provider. */
@Slf4j
public class CaptchaAuthenticationProvider implements AuthenticationProvider, InitializingBean, MessageSourceAware {
private final GrantedAuthoritiesMapper authoritiesMapper = new NullAuthoritiesMapper();
private final UserDetailsService userDetailsService;
private final CaptchaService captchaService;
private MessageSourceAccessor messages = SpringSecurityMessageSource.getAccessor();
public CaptchaAuthenticationProvider(UserDetailsService userDetailsService, CaptchaService captchaService) {
this.userDetailsService = userDetailsService;
this.captchaService = captchaService;
}
@Override
public Authentication authenticate(Authentication authentication) throws AuthenticationException {
Assert.isInstanceOf(CaptchaAuthenticationToken.class, authentication,
() -> messages.getMessage("CaptchaAuthenticationProvider.onlySupports",
"Only CaptchaAuthenticationToken is supported"));
CaptchaAuthenticationToken token = (CaptchaAuthenticationToken) authentication;
String phone = token.getName();
String rawCode = (String) token.getCredentials();
UserDetails user = userDetailsService.loadUserByUsername(phone);
if (Objects.isNull(user)) {
throw new BadCredentialsException("Bad credentials");
}
if (captchaService.verifyCaptcha(phone, rawCode)) {
return createSuccessAuthentication(authentication, user);
} else {
throw new BadCredentialsException("captcha is not matched");
}
}
@Override
public boolean supports(Class<?> authentication) {
return CaptchaAuthenticationToken.class.isAssignableFrom(authentication);
}
@Override
public void afterPropertiesSet() throws Exception {
Assert.notNull(userDetailsService, "userDetailsService must not be null");
Assert.notNull(captchaService, "captchaService must not be null");
}
@Override
public void setMessageSource(MessageSource messageSource) {
this.messages = new MessageSourceAccessor(messageSource);
}
protected Authentication createSuccessAuthentication(Authentication authentication, UserDetails user) {
Collection<? extends GrantedAuthority> authorities = authoritiesMapper.mapAuthorities(user.getAuthorities());
CaptchaAuthenticationToken result = new CaptchaAuthenticationToken(user, null, authorities);
result.setDetails(authentication.getDetails());
return result;
}
}Assemble the provider manager:
ProviderManager providerManager = new ProviderManager(Collections.singletonList(captchaAuthenticationProvider));3.3 Captcha Authentication Filter
package cn.felord.spring.security.captcha;
import org.springframework.lang.Nullable;
import org.springframework.security.authentication.AuthenticationServiceException;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.AuthenticationException;
import org.springframework.security.web.authentication.AbstractAuthenticationProcessingFilter;
import org.springframework.security.web.util.matcher.AntPathRequestMatcher;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
public class CaptchaAuthenticationFilter extends AbstractAuthenticationProcessingFilter {
public static final String SPRING_SECURITY_FORM_PHONE_KEY = "phone";
public static final String SPRING_SECURITY_FORM_CAPTCHA_KEY = "captcha";
public CaptchaAuthenticationFilter() {
super(new AntPathRequestMatcher("/clogin", "POST"));
}
@Override
public Authentication attemptAuthentication(HttpServletRequest request, HttpServletResponse response)
throws AuthenticationException {
if (!"POST".equals(request.getMethod())) {
throw new AuthenticationServiceException("Authentication method not supported: " + request.getMethod());
}
String phone = obtainPhone(request);
String captcha = obtainCaptcha(request);
phone = (phone != null) ? phone.trim() : "";
captcha = (captcha != null) ? captcha : "";
CaptchaAuthenticationToken authRequest = new CaptchaAuthenticationToken(phone, captcha);
setDetails(request, authRequest);
return this.getAuthenticationManager().authenticate(authRequest);
}
@Nullable
protected String obtainCaptcha(HttpServletRequest request) {
return request.getParameter(SPRING_SECURITY_FORM_CAPTCHA_KEY);
}
@Nullable
protected String obtainPhone(HttpServletRequest request) {
return request.getParameter(SPRING_SECURITY_FORM_PHONE_KEY);
}
protected void setDetails(HttpServletRequest request, CaptchaAuthenticationToken authRequest) {
authRequest.setDetails(authenticationDetailsSource.buildDetails(request));
}
}The filter intercepts a POST request such as:
POST /clogin?phone=1234567890&captcha=12345 HTTP/1.1
Host: localhost:80823.4 Configuration
package cn.felord.spring.security.captcha;
import cn.hutool.core.util.RandomUtil;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Qualifier;
import org.springframework.cache.annotation.CacheEvict;
import org.springframework.cache.annotation.CachePut;
import org.springframework.cache.annotation.Cacheable;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.authentication.ProviderManager;
import org.springframework.security.core.authority.AuthorityUtils;
import org.springframework.security.core.userdetails.User;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.web.authentication.AuthenticationFailureHandler;
import org.springframework.security.web.authentication.AuthenticationSuccessHandler;
import org.springframework.util.StringUtils;
import java.util.Collections;
import java.util.Objects;
/** Captcha authentication configuration. */
@Slf4j
@Configuration
public class CaptchaAuthenticationConfiguration {
private static final String SMS_CAPTCHA_CACHE = "captcha";
@Bean
CaptchaCacheStorage captchaCacheStorage() {
return new CaptchaCacheStorage() {
@CachePut(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
@Override
public String put(String phone) { return RandomUtil.randomNumbers(5); }
@Cacheable(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
@Override
public String get(String phone) { return null; }
@CacheEvict(cacheNames = SMS_CAPTCHA_CACHE, key = "#phone")
@Override
public void expire(String phone) { }
};
}
@Bean
CaptchaService captchaService(CaptchaCacheStorage captchaCacheStorage) {
return new CaptchaService() {
@Override
public boolean sendCaptcha(String phone) {
String existed = captchaCacheStorage.get(phone);
if (StringUtils.hasText(existed)) {
log.warn("captcha code [{}] is still available", existed);
return false;
}
String code = captchaCacheStorage.put(phone);
log.info("captcha: {}", code);
// TODO: call third‑party SMS provider
return true;
}
@Override
public boolean verifyCaptcha(String phone, String code) {
String cached = captchaCacheStorage.get(phone);
if (Objects.equals(cached, code)) {
captchaCacheStorage.expire(phone);
return true;
}
return false;
}
};
}
@Bean
@Qualifier("captchaUserDetailsService")
UserDetailsService captchaUserDetailsService() {
return username -> User.withUsername(username)
.password("TEMP")
.authorities(AuthorityUtils.createAuthorityList("ROLE_ADMIN", "ROLE_APP"))
.build();
}
@Bean
CaptchaAuthenticationProvider captchaAuthenticationProvider(CaptchaService captchaService,
@Qualifier("captchaUserDetailsService") UserDetailsService userDetailsService) {
return new CaptchaAuthenticationProvider(userDetailsService, captchaService);
}
@Bean
CaptchaAuthenticationFilter captchaAuthenticationFilter(AuthenticationSuccessHandler successHandler,
AuthenticationFailureHandler failureHandler,
CaptchaAuthenticationProvider provider) {
CaptchaAuthenticationFilter filter = new CaptchaAuthenticationFilter();
ProviderManager manager = new ProviderManager(Collections.singletonList(provider));
filter.setAuthenticationManager(manager);
filter.setAuthenticationSuccessHandler(successHandler);
filter.setAuthenticationFailureHandler(failureHandler);
return filter;
}
}Make sure the login and captcha endpoints are accessible anonymously (e.g., assign ROLE_ANONYMOUS if you use dynamic permissions).
4. Conclusion
By extending UsernamePasswordAuthenticationFilter and customizing AuthenticationManager, this article shows a full‑stack SMS captcha login implementation that coexists with traditional username/password authentication.
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Programmer DD
A tinkering programmer and author of "Spring Cloud Microservices in Action"
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
