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.

Programmer DD
Programmer DD
Programmer DD
Step‑by‑Step Guide to Implement SMS Captcha Login with Spring Security

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:8082

3.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.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Spring Bootspring-securitySMS captcha
Programmer DD
Written by

Programmer DD

A tinkering programmer and author of "Spring Cloud Microservices in Action"

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.