00:00

API Security Best Practices in System Design

Last updated: 18 min read

API security is the practice of protecting application programming interfaces from misuse, data theft and attacks. Modern apps are built on APIs: mobile apps, single-page web apps, partner integrations and microservices all talk through them. That makes APIs one of the most attractive targets for attackers, because a single weak endpoint can expose an entire database.

This tutorial explains the most common API threats and walks through twelve practical best practices: encryption, authentication, object-level authorization, input validation, rate limiting, minimal data exposure, safe error handling, secret management, security headers and CORS, API gateways, monitoring and API inventory. Each comes with clear examples, including Java and Spring Boot code, and the tutorial ends with a complete checklist.

1. What Is API Security?

An API is a set of endpoints that lets one program ask another to read or change data, such as GET /api/orders/1001 or POST /api/transfers. API security makes sure that:

  • only known callers can use the API (authentication),
  • each caller can only do what they are allowed to, on data they are allowed to touch (authorization),
  • data is protected in transit and at rest (encryption),
  • the API cannot be abused through malicious input or excessive traffic (validation and rate limiting),
  • misuse is detected and investigated quickly (logging and monitoring).

APIs are different from traditional web pages in one important way: there is no user interface hiding things. Attackers call endpoints directly with tools like curl or scripts, change IDs and parameters freely, and read every field in a response. Every rule must therefore be enforced on the server.

2. Common API Threats

The OWASP API Security Top 10 is a widely used list of the most critical API risks. In plain language, the main threats are:

ThreatWhat goes wrongSimple example
Broken object-level authorization (BOLA)The API does not check that the requested record belongs to the callerChanging /orders/1001 to /orders/1002 shows someone else's order
Broken authenticationWeak login, token or session handling lets attackers impersonate usersUnlimited password guesses, tokens that never expire
Broken property-level authorizationThe API returns or accepts fields the caller should not see or changeA response includes passwordHash, or a user sets "role": "ADMIN" in a profile update
Unrestricted resource consumptionNo limits on request rate, size or costA script requests 10,000 pages per second, or uploads a 5 GB file
Broken function-level authorizationAdmin or privileged endpoints are reachable by ordinary usersA normal user calls DELETE /api/admin/users/42
Abuse of sensitive business flowsLegitimate features are automated at scaleBots buying all concert tickets in seconds
Server-side request forgery (SSRF)The API fetches a URL supplied by the user without checksA "fetch image from URL" feature is used to reach internal servers
Security misconfigurationUnsafe defaults, verbose errors, open CORS, missing patchesStack traces returned to clients, debug endpoints left enabled
Improper inventory managementForgotten old or test APIs stay online without protection/v1/ endpoints still live after /v2/ fixed a flaw
Unsafe consumption of third-party APIsData from partner APIs is trusted blindlyA partner response containing malicious data is stored and executed

Injection attacks such as SQL injection, and attacks on data in transit, remain important too. The best practices below address all of these.

3. Defence in Depth: Layers of API Security

No single control is enough. Strong API security stacks several layers, so if one fails, the next still protects you:

Layers of API security from client to database A request travels from a client through four protective layers: an edge layer with a web application firewall and DDoS protection, an API gateway handling TLS, authentication and rate limiting, the service itself performing authorization and input validation, and the database with encryption and least-privilege access. Client Edge WAF DDoS protection bot detection API Gateway TLS termination token validation rate limiting Service authorization input validation safe responses Data encryption least privilege audit trail Logging, monitoring and alerting across every layer
Figure 1: Defence in depth: each layer between the client and the data adds its own protection, so a single mistake does not expose everything.

4. Best Practice 1: Encrypt Everything with HTTPS

Every API call must use HTTPS (TLS 1.2 or 1.3). Without it, tokens, passwords and personal data travel as readable text that anyone on the network path can capture or modify.

  • Reject or redirect plain HTTP requests, and send the Strict-Transport-Security header.
  • Never put secrets such as API keys, tokens or passwords in URLs; URLs end up in logs, browser history and proxy caches. Use headers instead.
  • Encrypt internal service-to-service traffic too, ideally with mutual TLS (mTLS), where both sides present certificates.
  • Encrypt sensitive data at rest in databases, backups and logs.

See HTTP vs HTTPS for how TLS works.

5. Best Practice 2: Authenticate Every Request

Authentication answers "who is calling?". Choose a method that fits the caller:

MethodHow it worksGood forWatch out for
API keysA long random string sent in a headerIdentifying partner applications, simple server-to-server useIdentifies an app, not a user; easy to leak; rotate regularly
OAuth 2.0 access tokensAn authorization server issues short-lived tokens after login or client authenticationUser-facing apps, third-party access, microservicesUse proven flows (Authorization Code with PKCE for apps); avoid deprecated flows
JWT (JSON Web Token)A signed token containing claims such as user ID, scopes and expiryStateless validation at gateways and servicesVerify signature, issuer, audience and expiry; keep lifetimes short
Mutual TLSBoth client and server present certificatesService-to-service and high-security partner integrationsCertificate issuing and rotation must be automated
Session cookiesServer-side session identified by a cookieTraditional browser-based web apps on the same domainRequire CSRF protection; use Secure, HttpOnly, SameSite

A typical modern setup uses OAuth 2.0 with JWT access tokens:

OAuth 2.0 access token flow for an API A client application authenticates with an authorization server and receives a short-lived JWT access token. It calls the API with the token in the Authorization header. The API gateway or resource server checks the token signature using the authorization server's public keys, which it fetches and caches, and then the service returns the response. Client app web, mobile, service Authorization Server issues signed tokens API Gateway validates every token Order Service 1. log in / client credentials 2. access token (JWT) 3. Authorization: Bearer … 4. public keys (cached) 5. verified call
Figure 2: The client gets a short-lived access token from the authorization server and sends it with every API call. The API verifies the token's signature with the server's public keys.
  • Keep access tokens short-lived (minutes, not days) and use refresh tokens to get new ones.
  • Always validate the token's signature, issuer, audience and expiry. Reject tokens with the none algorithm or unexpected algorithms.
  • Protect login endpoints with rate limits, account lockout or progressive delays, and multi-factor authentication for sensitive accounts.
  • Never build your own cryptography or token format; use well-tested libraries and identity providers.

Learn more in the OAuth2 and JWT tutorials.

6. Best Practice 3: Authorize Every Object and Action

Authentication tells you who the caller is; authorization decides what they may do. Broken object-level authorization is consistently ranked the number one API risk because it is so easy to get wrong: the developer checks that the user is logged in, but not that the requested record belongs to them.

Broken object level authorization compared with a secure check A user logged in as customer 7 requests order 1002, which belongs to customer 9. A vulnerable API returns the order because it only checks that the user is logged in. A secure API also checks that the order belongs to the caller and returns 404 Not Found. Customer 7 logged in GET /api/orders/1002 (order of customer 9) Vulnerable API checks login only Secure API checks login + ownership 200 OK another customer's data 404 Not Found nothing leaked
Figure 3: BOLA: the vulnerable API trusts any ID it receives. The secure API checks ownership on every request.
OrderController.java
package com.javabytechie.apisecurity;

import org.springframework.http.HttpStatus;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.server.ResponseStatusException;

@RestController
public class OrderController {

    private final OrderRepository orders;

    public OrderController(OrderRepository orders) {
        this.orders = orders;
    }

    // VULNERABLE: any logged-in user can read any order just by changing the id
    // @GetMapping("/api/orders/{id}")
    // public Order getOrder(@PathVariable long id) {
    //     return orders.findById(id).orElseThrow();
    // }

    // SECURE: the order is looked up together with the caller's identity from the verified token
    @GetMapping("/api/orders/{id}")
    public OrderResponse getOrder(@PathVariable long id, @AuthenticationPrincipal Jwt jwt) {
        String customerId = jwt.getSubject();
        return orders.findByIdAndCustomerId(id, customerId)
                .map(OrderResponse::from)
                .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND));
    }
}
  • Take the caller's identity from the verified token, never from a request parameter like ?userId=7.
  • Check ownership or permission on every endpoint that accepts an ID, including updates and deletes.
  • Return 404 rather than 403 for records the caller should not know exist.
  • Use random, non-guessable IDs (such as UUIDs) for public identifiers as an extra layer, but never as a replacement for authorization checks.
  • Protect admin functions with role or scope checks and keep them on separate paths, ideally a separate internal API. See Role Based Access Control.
  • Deny by default: an endpoint without an explicit rule should be inaccessible.

7. Best Practice 4: Validate All Input

Treat every value coming into your API as untrusted: path variables, query parameters, headers, JSON bodies and uploaded files. Validation stops injection attacks, crashes and corrupted data.

TransferController.java
package com.javabytechie.apisecurity;

import java.math.BigDecimal;

import jakarta.validation.Valid;
import jakarta.validation.constraints.DecimalMax;
import jakarta.validation.constraints.DecimalMin;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
import org.springframework.http.HttpStatus;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestController;

@RestController
public class TransferController {

    public record CreateTransferRequest(
            @NotBlank @Pattern(regexp = "^[A-Z0-9]{10,18}$") String toAccount,
            @NotNull @DecimalMin("1.00") @DecimalMax("200000.00") BigDecimal amount,
            @Size(max = 140) String note) { }

    public record TransferResponse(String reference, String status) { }

    @PostMapping("/api/transfers")
    @ResponseStatus(HttpStatus.CREATED)
    public TransferResponse create(@Valid @RequestBody CreateTransferRequest request) {
        // Only reached when every constraint passes; otherwise Spring returns 400 Bad Request
        return new TransferResponse("TRF-20261019-0001", "PENDING");
    }
}
  • Allow-list what is valid (types, lengths, ranges, formats, enum values) rather than trying to block bad patterns.
  • Use parameterised queries or an ORM for all database access; never build SQL by concatenating strings.
  • Reject unknown JSON fields for sensitive operations, so attackers cannot slip in extra properties such as role or balance (mass assignment).
  • Limit request body size, array lengths, nesting depth and file upload size and type.
  • When the API fetches URLs supplied by users, allow only approved domains and block internal IP ranges to prevent SSRF.
  • Encode output correctly for its destination (HTML, SQL, shell) if data is ever rendered or executed.

8. Best Practice 5: Rate Limiting and Throttling

Without limits, a single client, whether a buggy script, a scraper or an attacker, can exhaust your servers, run up your cloud bill or brute-force passwords. Rate limiting caps how many requests each client may make in a time window and answers excess requests with 429 Too Many Requests.

The token bucket algorithm is a popular choice because it allows short bursts while enforcing a steady average rate:

The token bucket rate limiting algorithm A bucket holds up to five tokens and is refilled at one token per second. Each incoming request takes one token and is allowed. When the bucket is empty, further requests are rejected with status 429 Too Many Requests. +1 token per second bucket: capacity 5 tokens Request 1–5 token taken → 200 OK Request 6 bucket empty → 429 retry after a token refills
Figure 4: Token bucket: tokens drip in at a fixed rate up to a maximum. Each request spends a token; with no tokens left, the request is rejected with 429.
TokenBucketRateLimiter.java
package com.javabytechie.apisecurity;

import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;

/** One token bucket per client: each request takes a token, tokens refill at a steady rate. */
public class TokenBucketRateLimiter {

    private static final class Bucket {
        double tokens;
        long lastRefillNanos;

        Bucket(double tokens, long now) {
            this.tokens = tokens;
            this.lastRefillNanos = now;
        }
    }

    private final int capacity;
    private final double tokensPerSecond;
    private final Map<String, Bucket> buckets = new ConcurrentHashMap<>();

    public TokenBucketRateLimiter(int capacity, double tokensPerSecond) {
        this.capacity = capacity;
        this.tokensPerSecond = tokensPerSecond;
    }

    public boolean allow(String clientId) {
        long now = System.nanoTime();
        Bucket bucket = buckets.computeIfAbsent(clientId, id -> new Bucket(capacity, now));
        synchronized (bucket) {
            double elapsedSeconds = (now - bucket.lastRefillNanos) / 1_000_000_000.0;
            bucket.tokens = Math.min(capacity, bucket.tokens + elapsedSeconds * tokensPerSecond);
            bucket.lastRefillNanos = now;
            if (bucket.tokens >= 1) {
                bucket.tokens -= 1;
                return true;
            }
            return false;
        }
    }

    public static void main(String[] args) throws InterruptedException {
        TokenBucketRateLimiter limiter = new TokenBucketRateLimiter(5, 1.0);   // burst of 5, then 1 request/second

        for (int i = 1; i <= 7; i++) {
            System.out.println("Request " + i + " from api-key-42: " + (limiter.allow("api-key-42") ? "200 OK" : "429 Too Many Requests"));
        }
        System.out.println("Request 1 from api-key-77: " + (limiter.allow("api-key-77") ? "200 OK" : "429 Too Many Requests"));

        Thread.sleep(1_100);   // wait a little over a second: one token is refilled
        System.out.println("After 1.1 s, api-key-42: " + (limiter.allow("api-key-42") ? "200 OK" : "429 Too Many Requests"));
    }
}
Output
Request 1 from api-key-42: 200 OK
Request 2 from api-key-42: 200 OK
Request 3 from api-key-42: 200 OK
Request 4 from api-key-42: 200 OK
Request 5 from api-key-42: 200 OK
Request 6 from api-key-42: 429 Too Many Requests
Request 7 from api-key-42: 429 Too Many Requests
Request 1 from api-key-77: 200 OK
After 1.1 s, api-key-42: 200 OK

Each client has its own bucket, so one noisy client does not affect others. In production with several API instances, keep the buckets in a shared store such as Redis, or enforce limits at the API gateway, so the limit applies across all servers. Also:

  • Set stricter limits on expensive or sensitive endpoints, such as login, password reset, search and exports.
  • Return a Retry-After header with 429 responses.
  • Limit by API key, user and IP address, and add timeouts and maximum page sizes so a single request cannot be too expensive.

Read more in the Rate Limiting tutorial.

9. Best Practice 6: Return Only the Data You Need

A common mistake is returning whole database entities and expecting the front end to hide sensitive fields. Attackers read raw API responses, so anything returned is exposed.

  • Use dedicated response DTOs (data transfer objects) that contain only the fields the client needs.
  • Never return secrets such as password hashes, internal flags, other users' data or full card numbers. Mask values like **** **** **** 4821.
  • Use separate request DTOs that contain only the fields a caller may change, which prevents mass assignment.
  • Paginate large collections and cap the page size.
  • For GraphQL APIs, limit query depth and complexity, and disable introspection in production if it is not needed.

10. Best Practice 7: Handle Errors Without Leaking Details

Stack traces, SQL errors and framework versions in error responses give attackers a map of your system. Return consistent, minimal errors to clients and keep the details in your logs:

ApiExceptionHandler.java
package com.javabytechie.apisecurity;

import java.util.UUID;

import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.http.HttpStatus;
import org.springframework.http.ProblemDetail;
import org.springframework.security.access.AccessDeniedException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.server.ResponseStatusException;

@RestControllerAdvice
public class ApiExceptionHandler {

    private static final Logger log = LoggerFactory.getLogger(ApiExceptionHandler.class);

    @ExceptionHandler(MethodArgumentNotValidException.class)
    public ProblemDetail handleValidation(MethodArgumentNotValidException ex) {
        ProblemDetail problem = ProblemDetail.forStatusAndDetail(HttpStatus.BAD_REQUEST, "Request validation failed");
        problem.setProperty("errors", ex.getBindingResult().getFieldErrors().stream()
                .map(error -> error.getField() + ": " + error.getDefaultMessage())
                .toList());
        return problem;
    }

    @ExceptionHandler(ResponseStatusException.class)
    public ProblemDetail handleStatus(ResponseStatusException ex) {
        return ProblemDetail.forStatus(ex.getStatusCode());
    }

    @ExceptionHandler(AccessDeniedException.class)
    public ProblemDetail handleDenied(AccessDeniedException ex) {
        return ProblemDetail.forStatusAndDetail(HttpStatus.FORBIDDEN, "Access denied");
    }

    @ExceptionHandler(Exception.class)
    public ProblemDetail handleUnexpected(Exception ex) {
        String errorId = UUID.randomUUID().toString();
        log.error("Unexpected error, reference {}", errorId, ex);      // full details stay in the server log
        return ProblemDetail.forStatusAndDetail(HttpStatus.INTERNAL_SERVER_ERROR,
                "Something went wrong. Quote reference " + errorId + " when contacting support.");
    }
}
Example error response (application/problem+json)
{
  "type": "about:blank",
  "title": "Internal Server Error",
  "status": 500,
  "detail": "Something went wrong. Quote reference 9b3f1e2a-6c41-4d8e-a0f7-52e1c9d4b8aa when contacting support.",
  "instance": "/api/transfers"
}

Use correct status codes (400, 401, 403, 404, 429, 500), and give login failures the same generic message whether the username or the password was wrong, so attackers cannot discover valid accounts.

11. Best Practice 8: Protect Secrets and Keys

  • Never hard-code API keys, database passwords or signing keys in source code, and never commit them to Git.
  • Store secrets in a secrets manager (such as HashiCorp Vault or a cloud secrets service) or inject them as environment variables at runtime.
  • Rotate keys and credentials regularly, and immediately after any suspected leak.
  • Give each client its own key so a leaked key can be revoked without affecting others.
  • Store only hashes of API keys on the server, just like passwords.
  • Scan repositories and build pipelines for accidentally committed secrets.

12. Best Practice 9: Security Headers and CORS

Browsers enforce useful protections, but only if your API tells them to:

  • Strict-Transport-Security: always use HTTPS for this domain.
  • X-Content-Type-Options: nosniff: stop browsers guessing content types.
  • Cache-Control: no-store: for responses containing personal or sensitive data.
  • Content-Security-Policy and frame-ancestors: mainly for any HTML the API serves, such as documentation pages.

CORS (Cross-Origin Resource Sharing) controls which websites may call your API from a browser. Allow only the exact origins you trust, for example https://app.example.com. Never combine Access-Control-Allow-Origin: * with credentials, and do not reflect whatever origin the request sends. Remember that CORS only restricts browsers; it does not replace authentication, because scripts and servers ignore it.

13. Best Practice 10: Use an API Gateway

An API gateway is a single entry point in front of your services. It is the natural place to enforce common protections consistently, instead of re-implementing them in every service:

  • TLS termination and HTTPS redirects.
  • Token validation and API key checks.
  • Rate limiting, quotas and request size limits.
  • IP allow and block lists, and integration with a Web Application Firewall.
  • Request logging, metrics and tracing headers.
  • Hiding internal services so only the gateway is reachable from the internet.

Services behind the gateway should still check authorization for their own data. The gateway answers "is this a valid caller?"; the service answers "may this caller touch this record?". See the API Gateway tutorial.

14. Best Practice 11: Log, Monitor and Audit

  • Log security events: logins, failed authentication, permission denials, rate-limit hits, token errors and administrative actions.
  • Include who, what, when and from where, plus a correlation ID that follows the request across services.
  • Never log passwords, tokens, full card numbers or other secrets; mask sensitive values.
  • Alert on unusual patterns, such as a spike in 401/403 responses, one client walking through sequential IDs, or traffic from unexpected countries.
  • Keep tamper-resistant audit logs for sensitive operations, and retain them as regulations require.

15. Best Practice 12: Keep an API Inventory and Lifecycle

  • Maintain an up-to-date list of every API, version, environment and owner. Forgotten "shadow" or "zombie" APIs are a common entry point.
  • Describe APIs with OpenAPI specifications, and use them to generate documentation, tests and gateway configuration.
  • Retire old versions on a published schedule instead of leaving them running indefinitely.
  • Keep test and staging APIs off the public internet, and never let them use real customer data without equal protection.
  • Validate and sanitise data received from third-party APIs just as carefully as user input, and set timeouts on outbound calls.
  • Include security testing in the delivery pipeline: dependency scanning, static analysis, and automated tests that try to access other users' data.

16. Putting It Together: A Secured Spring Boot API

Spring Boot with Spring Security's OAuth2 resource server support validates JWT access tokens with very little code. Add the spring-boot-starter-oauth2-resource-server dependency and point it at your identity provider:

application.yml
spring:
  security:
    oauth2:
      resourceserver:
        jwt:
          issuer-uri: https://auth.example.com/realms/shop   # keys are discovered and cached automatically
server:
  error:
    include-stacktrace: never
ApiSecurityConfig.java
package com.javabytechie.apisecurity;

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.HttpMethod;
import org.springframework.security.config.Customizer;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;

@Configuration
@EnableMethodSecurity
public class ApiSecurityConfig {

    @Bean
    SecurityFilterChain apiFilterChain(HttpSecurity http) throws Exception {
        http
            // Stateless API with bearer tokens: no server session, no cookies, so CSRF tokens are not needed
            .csrf(csrf -> csrf.disable())
            .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
            .authorizeHttpRequests(auth -> auth
                .requestMatchers("/actuator/health").permitAll()
                .requestMatchers(HttpMethod.GET, "/api/products/**").hasAuthority("SCOPE_products:read")
                .requestMatchers("/api/admin/**").hasAuthority("SCOPE_admin")
                .anyRequest().authenticated())                    // deny anonymous access by default
            .oauth2ResourceServer(oauth2 -> oauth2.jwt(Customizer.withDefaults()));
        return http.build();
    }
}

With this configuration, every request needs a valid, unexpired token signed by your identity provider; scopes inside the token decide access to product and admin endpoints; and the controller-level ownership check shown earlier protects individual records. Add the validation, error handling and rate limiting from the previous sections, and the API covers the most important risks.

17. API Security Checklist

AreaChecklist item
TransportHTTPS only, TLS 1.2+, HSTS, mTLS between services where possible
AuthenticationOAuth 2.0 / OIDC or scoped API keys; short-lived tokens; full JWT validation; protected login
AuthorizationOwnership check on every object; role/scope check on every function; deny by default
InputAllow-list validation, size limits, parameterised queries, no mass assignment, SSRF protection
OutputResponse DTOs, masked sensitive fields, pagination, no internal details
Abuse protectionRate limits per client, quotas, timeouts, bot protection for sensitive flows
ErrorsGeneric messages, correct status codes, no stack traces, reference IDs
SecretsSecrets manager, rotation, no secrets in code, URLs or logs
BrowserStrict CORS allow list, security headers, no-store for sensitive data
OperationsAPI gateway, WAF, security logging, alerting, audit trails
LifecycleAPI inventory, OpenAPI specs, version retirement, security testing in CI/CD, dependency updates

18. Summary

  • API security protects the endpoints that modern applications depend on, using several layers from the edge to the database.
  • The biggest risks are broken authorization (especially object-level), weak authentication, excessive data exposure, missing rate limits and misconfiguration.
  • Use HTTPS everywhere, OAuth 2.0 and JWT or scoped keys for authentication, and check ownership and permissions on every request.
  • Validate input, limit request rates, return only needed data and keep errors generic.
  • Protect secrets, configure CORS and headers carefully, use an API gateway, monitor everything and keep an inventory of every API you run.

Security is not a one-time feature but a habit built into every endpoint, review and deployment. Open the FAQ tab for answers to common questions about API security.