None
Image Source

Managing API keys tends to stay challenging for lots of backend developers and security engineers, particularly when live systems can't afford a break in traffic. A well-planned rotation process lets you replace old keys with new ones safely, keeping authentication secure while everything stays online. Modern Spring Boot projects make this possible through flexible security layers that can handle multiple active keys, temporary overlaps, and smooth transitions between them.

I publish free articles like this daily, if you want to support my work and get access to exclusive content and weekly recaps, consider subscribing to my Substack.

Setting Up API Key Authentication in Spring Boot

API key authentication gives an application a simple way to verify requests before they reach business logic. It works by assigning a unique token to each client or service and checking that token on every request. In Spring Boot, this can be done without complex identity frameworks while still being secure and modern. The setup has three main parts: a model to store and track keys, a filter to check requests, and deployment details to make sure it scales safely in production.

Designing the API Key Model

A database record for an API key needs more than the raw string. It should have metadata that tracks when the key was issued, if it's still valid, and who owns it. This helps when rotating keys, tracking usage, or removing compromised tokens.

Here's a starting example using JPA with fields that capture these details:

@Entity
@Table(name = "api_keys")
public class ApiKey {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(nullable = false, unique = true)
    private String keyHash;

    @Column(nullable = false)
    private boolean active = true;

    private String label;
    private Instant createdAt;
    private Instant expiresAt;
    private Instant lastUsedAt;
    private String owner;
    private Instant deactivatedAt;

    @PrePersist
    public void onCreate() {
        this.createdAt = Instant.now();
    }

    @PreUpdate
    public void onUpdate() {
        this.lastUsedAt = Instant.now();
    }

    // standard getters and setters
}

It's safer to store a hash rather than the raw key value. You can generate and hash a key before saving it, just like password storage.

public class ApiKeyGenerator {

    private static final SecureRandom random = new SecureRandom();

    public static String createRawKey() {
        byte[] bytes = new byte[32];
        random.nextBytes(bytes);
        return Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
    }

    public static String hashKey(String rawKey) {
        return DigestUtils.sha256Hex(rawKey);
    }
}

This prevents anyone who gains database access from reusing valid credentials. It also supports audit logging and safer key rotation later. You can pair this with an ApiKeyRepository that provides lookups by hashed value, keeping the application logic consistent.

When an application starts with a clean database, a bootstrap step can generate the first admin key. That key can later be replaced through a rotation flow without downtime.

Extracting and Validating the Key in Spring Security

Requests must be checked before they reach controllers or other handlers. Spring Security offers a way to hook into this process through filters. The modern configuration style doesn't rely on WebSecurityConfigurerAdapter anymore, as it's been deprecated. Instead, you define a SecurityFilterChain bean.

@Configuration
@EnableWebSecurity
@EnableScheduling
@EnableCaching
public class SecurityConfig {

    @Bean
    public SecurityFilterChain apiSecurity(HttpSecurity http, ApiKeyFilter apiKeyFilter) throws Exception {
        http
            .csrf(AbstractHttpConfigurer::disable)
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth.anyRequest().authenticated())
            .addFilterBefore(apiKeyFilter, UsernamePasswordAuthenticationFilter.class);

        return http.build();
    }
}

The filter handles key extraction and validation. It runs for every request and checks for the X-API-KEY header.

@Component
public class ApiKeyFilter extends OncePerRequestFilter {

    private final ApiKeyService apiKeyService;

    public ApiKeyFilter(ApiKeyService apiKeyService) {
        this.apiKeyService = apiKeyService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws IOException, ServletException {

        String key = request.getHeader("X-API-KEY");
        if (key == null || key.isBlank()) {
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Missing API key");
            return;
        }

        String hash = DigestUtils.sha256Hex(key);
        ApiKey apiKey = apiKeyService.findActiveByHash(hash);
        if (apiKey == null) {
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Invalid API key");
            return;
        }

        if (apiKey.getExpiresAt() != null && apiKey.getExpiresAt().isBefore(Instant.now())) {
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Expired API key");
            return;
        }

        apiKey.setLastUsedAt(Instant.now());
        apiKeyService.save(apiKey);

        Authentication auth =
            new UsernamePasswordAuthenticationToken(apiKey.getOwner(), null, List.of());
        SecurityContextHolder.getContext().setAuthentication(auth);
        chain.doFilter(request, response);
    }
}

For smaller services that don't use Spring Security, the same idea can be applied through a simple HandlerInterceptor. To match the filter's behavior, add an expiry check before allowing the request.

@Component
public class ApiKeyInterceptor implements HandlerInterceptor {

    private final ApiKeyService apiKeyService;

    public ApiKeyInterceptor(ApiKeyService apiKeyService) {
        this.apiKeyService = apiKeyService;
    }

    @Override
    public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler)
            throws Exception {

        String key = request.getHeader("X-API-KEY");
        if (key == null || key.isBlank()) {
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Missing API key");
            return false;
        }

        String hash = DigestUtils.sha256Hex(key);
        ApiKey apiKey = apiKeyService.findActiveByHash(hash);
        if (apiKey == null) {
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Invalid API key");
            return false;
        }

        if (apiKey.getExpiresAt() != null && apiKey.getExpiresAt().isBefore(Instant.now())) {
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Expired API key");
            return false;
        }

        apiKey.setLastUsedAt(Instant.now());
        apiKeyService.save(apiKey);
        return true;
    }
}

This interceptor can be registered through WebMvcConfigurer and works well for services that use lighter authentication needs.

Deployment Concerns for Live Traffic

Each incoming request triggers a lookup, which can create heavy database traffic if not optimized. A cache can help reduce pressure while still keeping the system secure. Spring provides easy caching through @Cacheable.

@Service
public class CachedApiKeyService {

    private final ApiKeyRepository repo;

    public CachedApiKeyService(ApiKeyRepository repo) {
        this.repo = repo;
    }

    @Cacheable(value = "apiKeys", key = "#hash")
    public ApiKey findActiveByHash(String hash) {
        return repo.findByKeyHashAndActiveTrue(hash).orElse(null);
    }
}

Short cache lifetimes, such as 30 seconds, work best so revocations still take effect quickly. This can be paired with distributed caches like Redis when multiple application instances share the same data source.

If your application supports many clients, rate limiting can prevent abuse. It helps stop a single compromised key from flooding your backend. A simple count-based rate limiter using Spring Boot's actuator metrics or libraries like Bucket4j can enforce limits on calls per minute per key.

Every deployment should include HTTPS, since API keys in headers are easily exposed without encryption. You should also log request metadata such as timestamps, endpoints, and client identifiers for auditing and debugging. A basic access log table is usually enough.

@Entity
public class ApiAccessLog {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String keyHash;
    private String endpoint;
    private Instant accessedAt;

    @PrePersist
    void onInsert() {
        this.accessedAt = Instant.now();
    }
}

Tracking this helps detect misuse or stale keys before they cause production incidents. When running multiple instances behind a load balancer, all nodes should share the same storage layer so that key updates and revocations are immediately visible to all. For very high traffic systems, a read-replica database or in-memory store can make authentication checks faster while still reliable.

A stable API key authentication layer serves as the foundation for later rotation steps. With the right schema, filter, and performance considerations, the service stays secure and responsive as traffic grows.

Rotating API Keys Without Interrupting Traffic

Replacing API keys in a production environment can feel risky because every live request depends on them. The process needs to be smooth enough that clients continue to send valid requests while the system accepts both old and new keys for a limited time. Proper rotation practices reduce security exposure without bringing services down or forcing emergency redeploys. The flow depends on supporting multiple active keys at once, maintaining tracking for usage, and making sure that each phase of the rotation is coordinated with monitoring.

Why Rotate and What to Support

API keys have lifespans, whether short or long. Over time, a key can become exposed through logs, client errors, or shared environments. Rotating them regularly limits how long a compromised key remains valid. Most organizations use automated policies that trigger rotation every few months or after specific events like user offboarding or application upgrades.

To make rotation smooth, the system needs the ability to store more than one active key for a single client. That allows both the outgoing and incoming keys to work at the same time. The old key remains usable until all traffic has switched to the new one. This transition window prevents sudden outages.

A basic structure that supports this looks like this repository query method:

public interface ApiKeyRepository extends JpaRepository<ApiKey, Long> {

    Optional<ApiKey> findByKeyHash(String keyHash);

    Optional<ApiKey> findByKeyHashAndActiveTrue(String keyHash);
}

This allows multiple active keys for the same owner, while still keeping filtering simple. Applications that rely on automated pipelines or service accounts can issue rotations without coordination delays between systems. Having expiry timestamps per key also creates natural end points for old keys without manual cleanup.

Two Phase Rotation Workflow

Safe key rotation works best as a two-phase process. The first phase introduces the new key and starts serving requests with both keys active. The second phase retires the old key after traffic has transitioned fully to the new one.

Phase one starts with generating a new key, storing it as active, and distributing it to the client or dependent system. The next step is updating configuration or deployment parameters to use the new key. During this period, both keys should authenticate correctly.

@Service
public class RotationManager {

    private final ApiKeyRepository repo;

    public RotationManager(ApiKeyRepository repo) {
        this.repo = repo;
    }

    public String issueNewKey(String owner) {
        String raw = ApiKeyGenerator.createRawKey();
        String hash = ApiKeyGenerator.hashKey(raw);

        ApiKey apiKey = new ApiKey();
        apiKey.setKeyHash(hash);
        apiKey.setOwner(owner);
        apiKey.setActive(true);
        apiKey.setCreatedAt(Instant.now());
        repo.save(apiKey);

        return raw;
    }
}

After the new key is issued, phase two begins with tracking usage. You want to confirm that most requests now use the new key before deactivating the old one. You can use aggregated access logs or metrics for that check. When the old key stops appearing in recent logs, it's ready to be retired.

@Transactional
@CacheEvict(value = "apiKeys", key = "T(org.apache.commons.codec.digest.DigestUtils).sha256Hex(#oldRawKey)")
public void retireOldKey(String oldRawKey) {
    String hash = DigestUtils.sha256Hex(oldRawKey);
    repo.findByKeyHashAndActiveTrue(hash).ifPresent(key -> {
        key.setActive(false);
        key.setDeactivatedAt(Instant.now());
        repo.save(key);
    });
}

A transition period of several days is common for client integrations that don't update instantly. For internal service-to-service communication, the window can be much shorter. Having both keys active makes sure that live traffic continues without any authentication failures.

Code Example of Rotation Support

Integrating rotation into an existing authentication filter can make the transition automatic. Instead of validating a single key at a time, you can check against all active keys for the owner.

@Component
public class RotationAwareFilter extends OncePerRequestFilter {

    private final CachedApiKeyService apiKeyService;

    public RotationAwareFilter(CachedApiKeyService apiKeyService) {
        this.apiKeyService = apiKeyService;
    }

    @Override
    protected void doFilterInternal(HttpServletRequest request,
                                    HttpServletResponse response,
                                    FilterChain chain)
            throws IOException, ServletException {

        String header = request.getHeader("X-API-KEY");
        if (header == null || header.isBlank()) {
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Missing API key");
            return;
        }

        String hash = DigestUtils.sha256Hex(header);
        ApiKey apiKey = apiKeyService.findActiveByHash(hash);
        if (apiKey == null) {
            response.sendError(HttpStatus.UNAUTHORIZED.value(), "Invalid or inactive API key");
            return;
        }

        Authentication auth = new UsernamePasswordAuthenticationToken(apiKey.getOwner(), null, List.of());
        SecurityContextHolder.getContext().setAuthentication(auth);
        chain.doFilter(request, response);
    }
}

This filter remains valid through rotations since both keys share the same validation path. It doesn't need redeployment or config edits when a new key is added. The active flags in the database handle which ones are accepted.

In some cases, you may want a background job to handle automatic expiration. That makes sure old keys don't stay active longer than intended.

@CacheEvict(value = "apiKeys", allEntries = true)
@Scheduled(cron = "0 0 * * * *")
public void deactivateExpiredKeys() {
    Instant now = Instant.now();
    List<ApiKey> expired = repo.findAll().stream()
        .filter(k -> k.getExpiresAt() != null && k.getExpiresAt().isBefore(now) && k.isActive())
        .toList();
    expired.forEach(k -> {
        k.setActive(false);
        k.setDeactivatedAt(now);
    });
    repo.saveAll(expired);
}

This basic scheduled task makes sure that rotation and expiration logic stay in sync. It prevents forgotten keys from silently staying active.

Monitoring and Traffic Transition

Rotation depends on data. You need to know when clients have switched to the new key so you can safely disable the old one. Collecting request metrics with timestamps and key hashes gives a precise picture of how traffic moves.

A small service can log access events directly to a database table:

@Entity
public class ApiUsageRecord {
    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;
    private String keyHash;
    private Instant usedAt = Instant.now();
    private String endpoint;
    private String ip;
}

Recording a few fields per request can be enough. For higher traffic systems, push these events to a message queue or monitoring tool to avoid slowing down the main flow.

Metrics aggregation then allows you to query something like:

SELECT key_hash, COUNT(*) 
FROM api_usage_record 
WHERE used_at > NOW() - INTERVAL '1 HOUR' 
GROUP BY key_hash;

If you see that the new key has most of the traffic, it's safe to deactivate the old one. Automating this analysis is possible with scheduled jobs that alert administrators or trigger automatic retirement once thresholds are met.

Rotations across multiple servers should share a synchronized cache or database layer so that usage data and active status are consistent. That coordination keeps authentication results predictable across nodes.

Handling Edge Cases

Even with solid automation, a few edge situations can appear. A client may not upgrade to the new key on time, or an integration might hold a cached key for too long. To reduce disruptions, you can apply a temporary fallback layer that allows inactive keys for a limited period but issues warning logs for each request.

@Service
public class GracePeriodValidator {

    private final ApiKeyRepository repo;

    public GracePeriodValidator(ApiKeyRepository repo) {
        this.repo = repo;
    }

    public boolean allowTemporarily(String rawKey) {
        String hash = DigestUtils.sha256Hex(rawKey);
        return repo.findByKeyHash(hash)
                .filter(k -> !k.isActive())
                .filter(k -> k.getDeactivatedAt() != null &&
                             k.getDeactivatedAt().isAfter(Instant.now().minus(Duration.ofHours(6))))
                .isPresent();
    }
}

That allows a short grace window while still tracking which clients lag behind. Another edge scenario is lost synchronization between distributed nodes where a key is deactivated in one instance but cached as active in another. A small cache eviction interval or pub-sub event can resolve that.

Network outages during rotation can also cause clients to miss configuration updates. Keeping the old key valid until confirmation prevents unintentional outages. With solid monitoring and short grace handling, even these unusual cases can be handled without traffic interruption.

Conclusion

Rotating API keys in a Spring Boot application works best when the process is treated like a steady relay rather than a sudden switch. The system holds both old and new keys for a short period, checks requests through well-tuned filters, and relies on monitoring to confirm when traffic has fully moved to the new credentials. With hashed storage, controlled overlap, and careful validation, the transition happens quietly in the background while live requests keep flowing without disruption.

  1. Spring Security Reference Guide
  2. Spring Boot Reference Documentation
  3. Spring Caching Documentation
  4. Spring Scheduling and Tasks
  5. Java SecureRandom Class Documentation

Thanks for reading! If you found this helpful, highlighting, clapping, or leaving a comment really helps me out.

None
Spring Boot icon by Icons8