Method Security, Authorization, and Secret Management

Method security protects the operation that actually performs work. URL security decides whether a request may reach a controller, but many Spring Boot applications also run the same use case from scheduled jobs, message listeners, tests, internal controllers, or GraphQL resolvers. Placing authorization on the service method keeps the rule close to the business action: cancel this invoice, read this document, rotate this credential, or approve this payment.

The outcome of this lesson is concrete: you should be able to enable Spring Security method authorization, choose between role checks and object-aware policies, understand when Spring evaluates the rule, and load secrets through configuration sources instead of hard-coding them. In this Spring Boot security section, this lesson sits after authentication because authorization decisions need an authenticated principal, granted authorities, and a predictable way to retrieve sensitive configuration.

How Method Security Works

Spring method security is implemented through Spring AOP proxies. When a bean method is called through its Spring-managed proxy, a security interceptor can run before or after the target method. The interceptor reads annotations such as @PreAuthorize, obtains the current Authentication from SecurityContextHolder, evaluates the authorization expression, and either allows the invocation or throws AccessDeniedException.

This has two important consequences. First, the secured object must be a Spring bean, and callers must go through the proxy. A call from one method to another method on the same instance bypasses the proxy, so the annotation on the second method will not be evaluated. Second, method security complements HTTP security instead of replacing it. HTTP rules remain useful for coarse routing decisions, while method rules protect use cases and domain-specific permissions.

Modern Spring Security method rules are built around authorization managers. A before-method interceptor handles @PreAuthorize and related pre-invocation checks. An after-method interceptor handles @PostAuthorize, which can inspect the returned object. Expressions are written with Spring Expression Language. Common names include authentication, principal, method arguments by name such as #accountId, and return values through returnObject in post-authorization.

Syntax And Configuration Anatomy

Method security is opt-in. Add @EnableMethodSecurity to a configuration class. Then place annotations on service methods, not only on controllers. Typical expressions use hasRole('ADMIN'), hasAuthority('invoice:approve'), isAuthenticated(), or a call to a policy bean such as @documentPolicy.canRead(authentication, #id). Prefer authorities for fine-grained permissions because roles often become broad and hard to audit.

import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity;

@Configuration
@EnableMethodSecurity
class MethodSecurityConfig {
}

The annotation above does not grant access by itself. It installs the method-security infrastructure so annotations on beans are enforced. If the application also has HTTP endpoints, configure the filter chain to authenticate requests and build an Authentication containing the expected authorities.

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
class InvoiceAdminService {
    @PreAuthorize("hasAuthority('invoice:refund')")
    public String refund(String invoiceId) {
        return "refunded " + invoiceId;
    }
}

Here the secured method requires the authority invoice:refund. A user with only ROLE_ADMIN is denied unless that role is also mapped to this authority. The deterministic behavior is simple: with the authority, the method returns refunded inv-100; without it, Spring throws AccessDeniedException before the method body runs.

Example 1: A Coarse Administrative Action

Start with a rule that does not depend on the target object. This is appropriate for actions where membership in a group is the entire decision, such as accessing an internal support-only maintenance command.

import java.util.Set;

public class AuthorityDemo {
    static boolean canRefund(Set<String> authorities) {
        return authorities.contains("invoice:refund");
    }

    public static void main(String[] args) {
        System.out.println(canRefund(Set.of("invoice:refund")));
        System.out.println(canRefund(Set.of("invoice:read")));
    }
}

The expected output is true and then false. The Spring annotation version delegates this same kind of decision to Spring Security at invocation time. The trade-off is speed and simplicity versus precision. A static authority check is easy to understand and test, but it cannot answer whether this user may refund this particular invoice.

Example 2: Ownership And Domain State

Most real authorization rules need both identity and state. A document reader may access public documents, documents they own, or documents shared with a group. Encoding all of that in a long annotation is hard to test, so put the decision in a Spring bean and call it from @PreAuthorize.

import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Service;

@Service
class DocumentService {
    @PreAuthorize("@documentPolicy.canRead(authentication, #documentId)")
    public String readDocument(String documentId) {
        return "document " + documentId;
    }
}
import java.util.Map;
import java.util.Set;

public class DocumentPolicyDemo {
    record User(String name, Set<String> groups) {}
    record Document(String owner, String group, boolean published) {}

    static boolean canRead(User user, Document document) {
        return document.published()
                || document.owner().equals(user.name())
                || user.groups().contains(document.group());
    }

    public static void main(String[] args) {
        User ana = new User("ana", Set.of("finance"));
        Map<String, Document> docs = Map.of(
                "q1", new Document("lee", "finance", false),
                "draft", new Document("lee", "legal", false));
        System.out.println(canRead(ana, docs.get("q1")));
        System.out.println(canRead(ana, docs.get("draft")));
    }
}

The expected output is true and then false. In a Spring application, documentPolicy would usually load the document metadata from a repository and inspect the authenticated user. Keep that policy method side-effect free. It should answer the permission question; it should not update the document, emit business events, or depend on a lazy transaction side effect that may differ between tests and production.

Example 3: Post-Authorization For Returned Objects

Sometimes the method must load the object before the decision can be made. @PostAuthorize evaluates after the method returns and can inspect returnObject. This is useful for single-object reads, but it is dangerous for lists because the data has already been loaded and may be expensive or sensitive.

import org.springframework.security.access.prepost.PostAuthorize;
import org.springframework.stereotype.Service;

@Service
class ProfileService {
    @PostAuthorize("returnObject.username == authentication.name")
    public Profile loadProfile(String username) {
        return new Profile(username, "standard");
    }

    record Profile(String username, String tier) {}
}

If authentication.name is maya, then loadProfile("maya") succeeds and returns the profile. loadProfile("niko") throws AccessDeniedException after the profile object is returned from the method body but before the caller receives it. For collection results, prefer repository queries that include the user scope, or use explicit filtering that is tested for performance and leakage.

Secret Management In Spring Boot

Secrets are credentials or tokens that grant capability: database passwords, API keys, signing keys, OAuth client secrets, and private keys. Spring Boot reads configuration through the Environment, which is assembled from property sources such as command-line arguments, environment variables, configuration files, config trees, and optional external systems. Your code should consume secrets from configuration, not from literals in source code.

payments:
  api-key: ${PAYMENTS_API_KEY}
  endpoint: https://payments.example.test
import org.springframework.boot.context.properties.ConfigurationProperties;

@ConfigurationProperties(prefix = "payments")
public record PaymentClientProperties(String apiKey, String endpoint) {
    public PaymentClientProperties {
        if (apiKey == null || apiKey.isBlank()) {
            throw new IllegalArgumentException("payments.api-key is required");
        }
    }
}

This configuration says the secret must arrive from PAYMENTS_API_KEY. The properties record validates that the application fails during startup if the key is absent. That failure is preferable to a partial deployment that accepts traffic and then fails every payment request. Do not print the key in logs, exception messages, actuator info endpoints, or test snapshots.

Design Choices And Trade-Offs

Use URL authorization for broad access to route families, such as requiring authentication for /api/**. Use method security for use-case rules, especially when the same operation has multiple entry points. Use policy beans when decisions need repositories, ownership, tenant membership, or domain state. Use inline expressions for short, obvious checks.

Prefer pre-authorization when possible because denied users do not execute the method body. Use post-authorization for single-object decisions that genuinely require the returned value. Avoid relying on post-filtering for large result sets because the application may fetch too much data before removing unauthorized items. For multi-tenant systems, enforce tenant scope in queries and database constraints as well as in method annotations.

For secrets, environment variables are simple and work well for local development and many container platforms. Mounted secret files or config trees reduce accidental exposure through process listings and can support rotation workflows. Dedicated secret managers add audit trails, dynamic credentials, and centralized rotation, but they introduce startup dependencies, permissions to manage, and failure modes to test.

Failure Modes And Troubleshooting

Symptom: a method annotated with @PreAuthorize still runs for an unauthorized user. Cause: method security was not enabled, the class was not a Spring bean, the method was called by self-invocation, or tests constructed the service with new. Diagnose: confirm @EnableMethodSecurity, check that the service is injected from the container, and add a negative integration test expecting AccessDeniedException. Correct: move the secured method to an injected bean or call it through the proxy.

Symptom: every request receives access denied despite successful login. Cause: the expression checks hasRole('ADMIN') while the authentication contains admin, ADMIN, or invoice:refund instead of ROLE_ADMIN. Diagnose: inspect granted authorities in a debugger or a sanitized test assertion. Correct: standardize authority mapping and prefer explicit permission names for business actions.

Symptom: the application starts locally but fails in the deployment environment with a missing property. Cause: the secret was supplied by an IDE run configuration or local shell but not by the deployment manifest. Diagnose: check resolved non-secret property names, environment injection, and configuration binding errors. Correct: add the secret to the platform secret store, reference it in the runtime environment, and keep startup validation in place.

Security, Performance, And Reliability Implications

Security improves when authorization is close to the action, but annotations can create false confidence if the team does not test denied paths. Performance depends on policy lookups. A policy that queries the database for every item in a loop can become an authorization bottleneck. Cache only stable permission data, include tenant and principal in cache keys, and expire entries when membership changes.

Reliability depends on clear startup behavior. Missing secrets should fail fast. Expired or rotated secrets should have a tested deployment path. If a secret manager is unavailable, decide whether the application should fail startup, use an already mounted value, or enter a degraded mode. That choice should match the risk of the operation being protected.

Hands-On Lab

Prerequisites: a Spring Boot application with Spring Security, a test framework, and one service bean that represents a protected use case. The lab can be done in an existing sample application or a new small project.

  1. Add @EnableMethodSecurity to a security configuration class.
  2. Create a service method named refund and annotate it with @PreAuthorize("hasAuthority('invoice:refund')").
  3. Add a test user or mock authentication with invoice:refund and verify the method returns the expected refund result.
  4. Add a second test with only invoice:read and verify the method throws AccessDeniedException.
  5. Add a payments.api-key configuration property sourced from PAYMENTS_API_KEY, bind it to a properties class, and validate that blank values fail startup.
  6. Run the application with the environment variable set. Then remove the variable and confirm startup fails before the application accepts traffic.

Verification: the positive authorization test succeeds, the negative authorization test fails before the protected method performs work, and missing secret configuration causes a startup failure with no secret value printed. Cleanup: remove any temporary local environment variables, delete throwaway test credentials, and revert sample users that were added only for the lab.

Assessment Exercises

  1. A controller endpoint and a message listener both call approveInvoice. Where should the authorization rule live, and what should still be configured at the HTTP layer?
  2. A team writes @PostAuthorize on a method returning 5,000 account records. Explain the leakage and performance risks, then propose a safer design.
  3. A policy bean checks document ownership by loading a record from the database. What tests would prove that unauthorized users cannot infer private document contents?
  4. A deployment fails because PAYMENTS_API_KEY is missing. Why is this usually better than allowing startup, and what information should the error message avoid?
  5. An annotation uses hasRole('ADMIN'), but authenticated users have invoice:refund. How would you diagnose and correct the mismatch?

Summary

Spring Boot method security uses Spring Security interceptors around proxied bean methods to evaluate authorization expressions against the current authentication, method arguments, and sometimes returned objects. Use it for service-level rules that must hold across entry points. Keep simple checks inline, move domain-aware decisions into policy beans, prefer pre-authorization when possible, and test both allowed and denied behavior. Manage secrets through Spring configuration sources, validate required values at startup, and avoid exposing secret values in logs or diagnostics.