API Key Management

Introduction & Lesson Overview

Welcome back! In the previous lessons, you learned how to generate secure API keys, store them safely, and use them to authenticate requests to your Spring Boot application. You also saw how to combine API key authentication with JWTs for flexible access control. Now that you have a solid foundation in creating and authenticating API keys, it is time to focus on managing them securely.

In this lesson, you will learn how to list your API keys in a way that protects sensitive information, how to revoke (deactivate) keys when they are no longer needed, and how to protect your API from abuse using rate limiting. These are essential skills for any real-world API, as they help you maintain security, support auditing, and prevent misuse. By the end of this lesson, you will be able to build robust API key management endpoints and understand how to integrate them into your Spring Boot application.

Listing API Keys Securely

When building an API key management system, it is important to allow users to view their keys — but you must never expose the full API key after it is created. This is a key security principle: if someone gains access to the list of keys, they should not be able to use them directly.

Let's look at how you can implement a secure listing endpoint. In the example below, the /api/api-keys/list route retrieves all API keys for the authenticated user. Instead of returning the full key, it provides a preview (just the prefix and asterisks), along with metadata such as the key's name, status, and expiration date.

@GetMapping("/list")
public ResponseEntity<?> listApiKeys(@RequestHeader(value = "authorization", required = false) String authorization) {
    var outcome = getCurrentUser(authorization);
    if (outcome.error() != null) return outcome.error();
    User user = outcome.user();

    List<ApiKey> userKeys = apiKeys.findByUserIdOrderByCreatedAtDesc(user.getId());

    List<Map<String, Object>> keysWithStatus = userKeys.stream().map(key -> {
        LocalDateTime now = LocalDateTime.now();
        boolean isExpired = now.isAfter(key.getExpiresAt());
        long daysUntilExpiry = ChronoUnit.DAYS.between(now, key.getExpiresAt());

        String status = isExpired ? "expired" : (daysUntilExpiry < 30 ? "expiring_soon" : "active");

        Map<String, Object> keyInfo = new LinkedHashMap<>();
        keyInfo.put("id", key.getId());
        keyInfo.put("name", key.getName());
        keyInfo.put("keyPreview", "pb_" + "*".repeat(60));  // Hide the actual key
        keyInfo.put("isActive", key.getIsActive());
        keyInfo.put("isExpired", isExpired);
        keyInfo.put("expiresAt", key.getExpiresAt().toString());
        keyInfo.put("createdAt", key.getCreatedAt().toString());
        keyInfo.put("status", status);

        return keyInfo;
    }).collect(Collectors.toList());

    long activeCount = keysWithStatus.stream()
            .filter(k -> (Boolean) k.get("isActive") && !(Boolean) k.get("isExpired"))
            .count();

    Map<String, Object> response = new LinkedHashMap<>();
    response.put("apiKeys", keysWithStatus);
    response.put("totalCount", keysWithStatus.size());
    response.put("activeCount", activeCount);

    return ResponseEntity.ok(response);
}

In this code, the endpoint first fetches all API keys for the current user using the JPA repository method findByUserIdOrderByCreatedAtDesc(). This is a Spring Data JPA derived query method — Spring automatically generates the implementation based on the method name. The findByUserId part tells Spring to filter by the userId field, OrderBy sorts the results, CreatedAt specifies which field to sort by, and Desc means descending order (newest first). So this method returns all keys for the user, sorted from most recent to oldest.

For each key, the code calculates whether the key is expired and how many days remain until expiration using ChronoUnit.DAYS.between(). The keyPreview field is set to a string like pb_************************************************************, so the actual key value is never exposed. The status is set to expired, expiring_soon, or active based on the expiration date.

Notice the use of LinkedHashMap instead of HashMap for the response maps. This is important because LinkedHashMap preserves insertion order — the order in which you put items into the map. When Spring Boot converts these maps to JSON, the fields appear in the exact order they were added, making the API response predictable and easier to read. A regular HashMap would return fields in arbitrary order, which could confuse API consumers who expect consistent responses.

The response includes a list of keys with their metadata, as well as counts of total and active keys.

A sample response might look like this:

{
  "apiKeys": [
    {
      "id": 1,
      "name": "My First Key",
      "keyPreview": "pb_************************************************************",
      "isActive": true,
      "isExpired": false,
      "expiresAt": "2024-07-10T12:00:00",
      "createdAt": "2024-06-10T12:00:00",
      "status": "active"
    }
  ],
  "totalCount": 1,
  "activeCount": 1
}

This approach allows users to manage their keys without risking exposure of sensitive information.

Secure API Key Revocation

Sometimes, you need to disable an API key — maybe it was leaked, or it is no longer needed. Instead of deleting the key from the database, it is best practice to deactivate it. This keeps an audit trail, which is important for security and compliance. Deactivated keys can no longer be used, but you still have a record of their existence and history.

Here is how you can implement a revocation endpoint:

@DeleteMapping("/{keyId}")
public ResponseEntity<?> revokeApiKey(@PathVariable Integer keyId,
                                      @RequestHeader(value = "authorization", required = false) String authorization) {
    var outcome = getCurrentUser(authorization);
    if (outcome.error() != null) return outcome.error();
    User user = outcome.user();

    Optional<ApiKey> apiKeyOpt = apiKeys.findById(keyId);
    if (apiKeyOpt.isEmpty()) {
        return error(HttpStatus.NOT_FOUND, "API key not found");
    }

    ApiKey apiKey = apiKeyOpt.get();
    if (!apiKey.getUserId().equals(user.getId())) {
        return error(HttpStatus.FORBIDDEN, "Access denied");
    }

    Integer revokedKeyId = apiKey.getId();
    String revokedKeyName = apiKey.getName();

    // Deactivate instead of deleting for audit trail
    apiKey.setIsActive(false);
    apiKeys.save(apiKey);

    System.out.println("API key revoked: keyId=" + revokedKeyId + ", userId=" + user.getId() + ", keyName=" + revokedKeyName);

    Map<String, Object> revokedKey = new LinkedHashMap<>();
    revokedKey.put("id", revokedKeyId);
    revokedKey.put("name", revokedKeyName);

    Map<String, Object> response = new LinkedHashMap<>();
    response.put("message", "API key revoked successfully");
    response.put("revokedKey", revokedKey);

    return ResponseEntity.ok(response);
}

In this code, the endpoint looks up the API key by its ID and the current user using the JPA repository. If the key is found, it sets isActive to false and saves the change to the database. The action is logged for auditing. The response confirms the revocation and includes the key's ID and name.

A typical response would be:

{
  "message": "API key revoked successfully",
  "revokedKey": {
    "id": 1,
    "name": "My First Key"
  }
}

By deactivating rather than deleting, you ensure that you can always review which keys existed and when they were revoked.

Implementing Rate Limiting For API Key Requests

As your API grows, it is important to protect it from abuse. One common attack is to flood your API with requests, which can slow down or even crash your service. Rate limiting helps prevent this by restricting how many requests a user or API key can make in a given time period.

In Java/Spring Boot applications, you can use the Bucket4j library to implement rate limiting. In this example, you configure rate limiting to allow each API key 100 requests per hour. The filter checks if the request is authenticated with an API key and applies the limit accordingly. JWT and unauthenticated requests skip rate limiting entirely, as they typically represent interactive users who are less likely to abuse the API.

Here's how to implement the rate limiting filter:

package com.codesignal.pastebin.filter;

import com.fasterxml.jackson.databind.ObjectMapper;
import io.github.bucket4j.Bandwidth;
import io.github.bucket4j.Bucket;
import io.github.bucket4j.ConsumptionProbe;
import io.github.bucket4j.local.LocalBucket;
import jakarta.servlet.*;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;

import java.io.IOException;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
import java.util.concurrent.ConcurrentHashMap;

@Component
public class RateLimitFilter implements Filter {
    private final Map<String, LocalBucket> buckets = new ConcurrentHashMap<>();
    private final ObjectMapper objectMapper = new ObjectMapper();

    @Override
    public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
            throws IOException, ServletException {
        HttpServletRequest httpRequest = (HttpServletRequest) request;
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        // Generate rate limit key based on auth method
        String rateLimitKey = getRateLimitKey(httpRequest);

        // Skip rate limiting for non-API-key requests
        if (rateLimitKey.startsWith("bypass_")) {
            chain.doFilter(request, response);
            return;
        }

        // Get or create bucket for this API key
        LocalBucket bucket = buckets.computeIfAbsent(rateLimitKey, key -> 
            Bucket.builder()
                .addLimit(Bandwidth.simple(100, Duration.ofHours(1)))
                .build()
        );

        ConsumptionProbe probe = bucket.tryConsumeAndReturnRemaining(1);

        if (probe.isConsumed()) {
            // Add rate limit headers
            httpResponse.addHeader("X-RateLimit-Limit", "100");
            httpResponse.addHeader("X-RateLimit-Remaining", String.valueOf(probe.getRemainingTokens()));
            httpResponse.addHeader("X-RateLimit-Reset", String.valueOf(
                System.currentTimeMillis() / 1000 + probe.getNanosToWaitForRefill() / 1_000_000_000
            ));
            chain.doFilter(request, response);
        } else {
            // Rate limit exceeded
            logRateLimitViolation(httpRequest);
            sendRateLimitError(httpResponse);
        }
    }

    private String getRateLimitKey(HttpServletRequest request) {
        String authMethod = (String) request.getAttribute("auth_method");
        
        if ("api_key".equals(authMethod)) {
            Integer apiKeyId = (Integer) request.getAttribute("api_key_id");
            return "api_key_" + apiKeyId;
        }
        
        // For non-API-key requests, return unique bypass key
        return "bypass_" + UUID.randomUUID();
    }

    private void logRateLimitViolation(HttpServletRequest request) {
        Integer apiKeyId = (Integer) request.getAttribute("api_key_id");
        String ip = request.getRemoteAddr();
        String userAgent = request.getHeader("user-agent");
        
        System.out.println("Rate limit exceeded: api_key_id=" + apiKeyId + 
            ", ip=" + ip + ", user_agent=" + userAgent);
    }

    private void sendRateLimitError(HttpServletResponse response) throws IOException {
        response.setStatus(429);
        response.setContentType("application/json");
        
        Map<String, Object> error = new LinkedHashMap<>();
        error.put("error", "Rate limit exceeded");
        error.put("message", "Too many requests. Limit: 100 per hour");
        error.put("retryAfter", "1 hour");
        
        objectMapper.writeValue(response.getWriter(), error);
    }
}

The Bucket4j library automatically manages rate limiting state and calculates the remaining tokens. The ConcurrentHashMap stores a bucket for each API key ID, and the computeIfAbsent method ensures thread-safe bucket creation. Each bucket is configured with a bandwidth of 100 requests per hour using Bandwidth.simple().

The getRateLimitKey() method is the heart of the bypass logic. When a request comes in with API key authentication, it returns a key like api_key_123, which is stored in the buckets map and tracked across requests. For JWT or unauthenticated requests, it returns bypass_ followed by a random UUID. This works because each non-API-key request gets a unique key that has never been seen before. Since the key is unique, computeIfAbsent creates a fresh bucket with 100 available tokens, and the request immediately succeeds. The bucket is never reused because the next request will have a different UUID. This means JWT and unauthenticated requests effectively have no rate limit.

The security trade-off here is intentional: API keys are designed for automated, high-volume access where abuse is more likely, so they need strict limits. JWT authentication requires users to log in and is typically used for interactive sessions, making abuse less likely and rate limiting less necessary. However, be aware that this approach means unauthenticated endpoints are also unprotected from abuse — in a production system, you might want to apply IP-based rate limiting to public endpoints instead of skipping limits entirely.

When a client makes API key requests, they will see dynamic headers in the response:

X-RateLimit-Limit: 100
X-RateLimit-Remaining: 94
X-RateLimit-Reset: 1699564800

These headers decrement with each request, showing the client exactly how many requests they have left and when the limit resets. If a user exceeds the limit, they receive a 429 error:

{
  "error": "Rate limit exceeded",
  "message": "Too many requests. Limit: 100 per hour",
  "retryAfter": "1 hour"
}

This helps protect your API from accidental or malicious overuse by automated systems using API keys, while providing clear, real-time feedback about usage limits through dynamic headers. JWT and unauthenticated requests bypass rate limiting entirely by using unique bypass keys that are never tracked.

Integrating Management Endpoints Into Spring Boot

Now that you have endpoints for listing and revoking API keys, and a rate limiting setup, you need to integrate them into your main Spring Boot application. This ensures that all API routes are protected and that key management is available to authenticated users.

Here is how the controller and filters are set up:

package com.codesignal.pastebin.controller;

import com.auth0.jwt.interfaces.DecodedJWT;
import com.codesignal.pastebin.model.ApiKey;
import com.codesignal.pastebin.model.User;
import com.codesignal.pastebin.repo.ApiKeyRepository;
import com.codesignal.pastebin.repo.UserRepository;
import com.codesignal.pastebin.util.ErrorResponse;
import com.codesignal.pastebin.util.JwtUtil;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.web.bind.annotation.*;

import java.security.SecureRandom;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.*;
import java.util.stream.Collectors;

@RestController
@RequestMapping("/api/api-keys")
public class ApiKeyController {
    private final ApiKeyRepository apiKeys;
    private final UserRepository users;
    private final JwtUtil jwt;
    private final PasswordEncoder passwordEncoder;

    public ApiKeyController(ApiKeyRepository apiKeys, UserRepository users, 
                           JwtUtil jwt, PasswordEncoder passwordEncoder) {
        this.apiKeys = apiKeys;
        this.users = users;
        this.jwt = jwt;
        this.passwordEncoder = passwordEncoder;
    }

    // Create, list, and revoke endpoints as shown above
    
    private AuthOutcome getCurrentUser(String authorizationHeader) {
        if (authorizationHeader == null || authorizationHeader.isBlank()) {
            return new AuthOutcome(null, error(HttpStatus.UNAUTHORIZED, "Missing authorization header"));
        }
        String token = authorizationHeader.startsWith("Bearer ") ? 
            authorizationHeader.substring(7) : authorizationHeader;
        try {
            DecodedJWT decoded = jwt.verify(token);
            Integer userId = decoded.getClaim("userId").asInt();
            return users.findById(userId)
                    .map(user -> new AuthOutcome(user, null))
                    .orElseGet(() -> new AuthOutcome(null, 
                        error(HttpStatus.UNAUTHORIZED, "User not found")));
        } catch (Exception e) {
            return new AuthOutcome(null, error(HttpStatus.UNAUTHORIZED, "Invalid token"));
        }
    }

    private ResponseEntity<ErrorResponse> error(HttpStatus status, String detail) {
        return ResponseEntity.status(status).body(new ErrorResponse(detail));
    }

    private record AuthOutcome(User user, ResponseEntity<ErrorResponse> error) {}
}

The rate limiting filter integrates automatically through Spring Boot's filter chain. Register the filter by adding a @Component annotation to the RateLimitFilter class, and Spring Boot will automatically apply it to all requests. The filter checks the authMethod and apiKeyId attributes that should be set by an authentication filter earlier in the chain.

This structure keeps your application organized and secure, ensuring that automated API access is protected by rate limiting while interactive users can work without restrictions.

Summary & Next Steps

In this lesson, you learned how to manage API keys securely in your Spring Boot application. You saw how to list API keys without exposing sensitive information, how to revoke keys safely for audit purposes, and how to protect your API from abuse using rate limiting that specifically targets API key requests while allowing interactive users unrestricted access. You also learned how to integrate these features into your main Spring Boot application for a clean and secure architecture.

These skills are essential for any API that uses key-based authentication. They help you keep your users safe, support compliance, and maintain the reliability of your service. In the next set of practice exercises, you will get hands-on experience with these concepts, reinforcing what you have learned and preparing you to build secure, production-ready APIs. Remember, on CodeSignal, all the necessary libraries are pre-installed, so you can focus on writing and testing your code. Good luck, and keep building your security skills!

Sign up

Join the 1M+ learners on CodeSignal

Be a part of our community of 1M+ users who develop and demonstrate their skills on CodeSignal