Method Security & OAuth2
"URL-based security tells you where the gate is; method security tells you who is allowed through each door inside the building."
Real Incident: IDOR via Missing Method-Level Authorization
A SaaS platform secured endpoints with URL patterns: .requestMatchers("/api/accounts/{id}/**").authenticated(). Any logged-in user could change the {id} in the URL and access another user's account data — classic Insecure Direct Object Reference (IDOR). The root cause: no @PreAuthorize check verifying that #id belonged to the authenticated principal. URL-level security only confirms authentication; method-level security enforces authorization per resource.
Method Security
Enabling Method Security
@Configuration
@EnableMethodSecurity // Spring Security 6+ (replaces @EnableGlobalMethodSecurity)
public class MethodSecurityConfig {
// Enables @PreAuthorize, @PostAuthorize, @PreFilter, @PostFilter by default
// For @Secured support, use @EnableMethodSecurity(securedEnabled = true)
}
Migration Note
@EnableGlobalMethodSecurity is deprecated since Spring Security 6.0. Use @EnableMethodSecurity which uses proxy-based AOP by default and supports meta-annotations.
@PreAuthorize — Before Method Execution
Evaluates a SpEL expression before the method runs. If the expression returns false, an AccessDeniedException is thrown.
@Service
public class AccountService {
// Role-based
@PreAuthorize("hasRole('ADMIN')")
public void deleteAccount(Long accountId) { ... }
// Permission-based with method argument access
@PreAuthorize("#accountId == authentication.principal.id or hasRole('ADMIN')")
public Account getAccount(Long accountId) { ... }
// Custom bean-based check
@PreAuthorize("@accountSecurity.isOwner(#accountId, authentication)")
public void updateAccount(Long accountId, AccountDto dto) { ... }
}
Common SpEL Expressions:
| Expression | Description |
|---|---|
hasRole('ADMIN') | User has ROLE_ADMIN |
hasAnyRole('ADMIN','MANAGER') | User has any listed role |
hasAuthority('account:write') | User has exact authority |
#paramName == authentication.principal.id | Method arg matches principal |
@beanName.method(args) | Delegates to a Spring bean |
authentication.principal.claims['tenant_id'] | JWT claim access |
@PostAuthorize — After Method Execution
Evaluates after the method returns. Use when the authorization decision depends on the return value.
@PostAuthorize("returnObject.owner == authentication.name")
public Document getDocument(Long docId) {
return documentRepository.findById(docId).orElseThrow();
}
Performance Consideration
@PostAuthorize executes the full method body before checking authorization. Avoid on expensive operations — prefer @PreAuthorize with a repository lookup when possible.
@PreFilter and @PostFilter — Collection Filtering
Filter collections before (input) or after (output) method execution without throwing exceptions.
// Filter input: only keep items the user owns
@PreFilter("filterObject.owner == authentication.name")
public void batchDelete(List<Document> documents) {
documentRepository.deleteAll(documents); // only user's own docs reach here
}
// Filter output: remove items the user shouldn't see
@PostFilter("filterObject.department == authentication.principal.department")
public List<Report> getAllReports() {
return reportRepository.findAll(); // non-matching items silently removed
}
@Secured — Simple Role-Based
Simpler than @PreAuthorize — no SpEL, only role checks.
Must be explicitly enabled: @EnableMethodSecurity(securedEnabled = true)
Custom Permission Evaluator
For complex domain-specific authorization logic:
@Component
public class CustomPermissionEvaluator implements PermissionEvaluator {
@Override
public boolean hasPermission(Authentication auth, Object target, Object permission) {
if (target instanceof Document doc) {
return switch (permission.toString()) {
case "READ" -> doc.isPublic() || doc.getOwner().equals(auth.getName());
case "WRITE" -> doc.getOwner().equals(auth.getName());
case "DELETE" -> hasRole(auth, "ADMIN") || doc.getOwner().equals(auth.getName());
default -> false;
};
}
return false;
}
@Override
public boolean hasPermission(Authentication auth, Serializable targetId,
String targetType, Object permission) {
// Load target from DB by id and type, then delegate to above
return false;
}
}
Usage in annotations:
@PreAuthorize("hasPermission(#doc, 'WRITE')")
public void updateDocument(Document doc) { ... }
@PreAuthorize("hasPermission(#docId, 'Document', 'DELETE')")
public void deleteDocument(Long docId) { ... }
Authorization Architecture
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
MC["Method Call"] --> AOP["Spring AOP<br/>Interceptor"]
AOP --> AM["Authorization<br/>Manager"]
AM --> PRE["@PreAuthorize<br/>Handler"]
AM --> POST["@PostAuthorize<br/>Handler"]
AM --> SEC["@Secured<br/>Handler"]
PRE --> SPEL["SpEL<br/>Evaluator"]
SPEL --> SC["Security<br/>Context"]
SC --> DECISION{Granted?}
DECISION -->|Yes| TARGET["Target Method"]
DECISION -->|No| DENY["AccessDenied<br/>Exception"]
style MC fill:#DBEAFE,stroke:#93C5FD,stroke-width:2px,color:#1E40AF
style AOP fill:#DBEAFE,stroke:#93C5FD,stroke-width:2px,color:#1E40AF
style AM fill:#EFF6FF,stroke:#93C5FD,stroke-width:2px,color:#1E40AF
style PRE fill:#D1FAE5,stroke:#6EE7B7,stroke-width:2px,color:#065F46
style POST fill:#D1FAE5,stroke:#6EE7B7,stroke-width:2px,color:#065F46
style SEC fill:#D1FAE5,stroke:#6EE7B7,stroke-width:2px,color:#065F46
style SPEL fill:#FEF3C7,stroke:#FCD34D,stroke-width:2px,color:#92400E
style SC fill:#FEF3C7,stroke:#FCD34D,stroke-width:2px,color:#92400E
style DECISION fill:#FFFBEB,stroke:#FCD34D,stroke-width:2px,color:#92400E
style TARGET fill:#ECFDF5,stroke:#6EE7B7,stroke-width:2px,color:#065F46
style DENY fill:#FEE2E2,stroke:#FCA5A5,stroke-width:2px,color:#991B1B OAuth2 Resource Server
A Resource Server validates tokens and extracts authorities — it does not handle login flows.
JWT Validation Flow
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
CLIENT["Client App"] -->|Bearer Token| RS["Resource<br/>Server"]
RS --> BF["BearerToken<br/>Filter"]
BF --> JD["JwtDecoder"]
JD --> JWK["JWKS Endpoint<br/>(Auth Server)"]
JD --> VALID{Valid?}
VALID -->|Yes| CONV["JwtAuthentication<br/>Converter"]
CONV --> CTX["SecurityContext<br/>Populated"]
VALID -->|No| ERR["401<br/>Unauthorized"]
style CLIENT fill:#DBEAFE,stroke:#93C5FD,stroke-width:2px,color:#1E40AF
style RS fill:#EFF6FF,stroke:#93C5FD,stroke-width:2px,color:#1E40AF
style BF fill:#DBEAFE,stroke:#93C5FD,stroke-width:2px,color:#1E40AF
style JD fill:#D1FAE5,stroke:#6EE7B7,stroke-width:2px,color:#065F46
style JWK fill:#D1FAE5,stroke:#6EE7B7,stroke-width:2px,color:#065F46
style VALID fill:#FFFBEB,stroke:#FCD34D,stroke-width:2px,color:#92400E
style CONV fill:#FEF3C7,stroke:#FCD34D,stroke-width:2px,color:#92400E
style CTX fill:#ECFDF5,stroke:#6EE7B7,stroke-width:2px,color:#065F46
style ERR fill:#FEE2E2,stroke:#FCA5A5,stroke-width:2px,color:#991B1B Configuration
# application.yml
spring:
security:
oauth2:
resourceserver:
jwt:
issuer-uri: https://auth.example.com/realms/myapp
# OR specify JWK Set URI directly:
# jwk-set-uri: https://auth.example.com/.well-known/jwks.json
JwtDecoder and Custom Validation
@Bean
public JwtDecoder jwtDecoder() {
NimbusJwtDecoder decoder = NimbusJwtDecoder
.withJwkSetUri("https://auth.example.com/.well-known/jwks.json")
.build();
// Add custom validators
OAuth2TokenValidator<Jwt> audienceValidator = token -> {
if (token.getAudience().contains("my-api")) {
return OAuth2TokenValidatorResult.success();
}
return OAuth2TokenValidatorResult.failure(
new OAuth2Error("invalid_audience", "Expected audience 'my-api'", null)
);
};
OAuth2TokenValidator<Jwt> withIssuer = JwtValidators.createDefaultWithIssuer(
"https://auth.example.com/realms/myapp"
);
decoder.setJwtValidator(new DelegatingOAuth2TokenValidator<>(withIssuer, audienceValidator));
return decoder;
}
JwtAuthenticationConverter — Custom Claim to Authority Mapping
@Bean
public JwtAuthenticationConverter jwtAuthenticationConverter() {
JwtGrantedAuthoritiesConverter grantedAuthorities = new JwtGrantedAuthoritiesConverter();
// Default reads "scope" or "scp" claim and prefixes with "SCOPE_"
// For custom roles claim (e.g., from Keycloak realm_access.roles):
JwtAuthenticationConverter converter = new JwtAuthenticationConverter();
converter.setJwtGrantedAuthoritiesConverter(jwt -> {
Collection<GrantedAuthority> authorities = new ArrayList<>(
grantedAuthorities.convert(jwt) // keep scope-based authorities
);
// Extract roles from nested claim
Map<String, Object> realmAccess = jwt.getClaimAsMap("realm_access");
if (realmAccess != null) {
List<String> roles = (List<String>) realmAccess.get("roles");
roles.stream()
.map(role -> new SimpleGrantedAuthority("ROLE_" + role.toUpperCase()))
.forEach(authorities::add);
}
return authorities;
});
return converter;
}
Security Configuration for Resource Server
@Configuration
@EnableWebSecurity
@EnableMethodSecurity
public class ResourceServerConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/public/**").permitAll()
.requestMatchers("/api/admin/**").hasRole("ADMIN")
.anyRequest().authenticated()
)
.oauth2ResourceServer(oauth2 -> oauth2
.jwt(jwt -> jwt.jwtAuthenticationConverter(jwtAuthenticationConverter()))
)
.sessionManagement(session ->
session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)
)
.build();
}
}
Opaque Token Introspection
For tokens that are not self-contained (not JWT) — the resource server calls the auth server's introspection endpoint on every request.
spring:
security:
oauth2:
resourceserver:
opaquetoken:
introspection-uri: https://auth.example.com/oauth2/introspect
client-id: my-resource-server
client-secret: ${INTROSPECT_SECRET}
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.oauth2ResourceServer(oauth2 -> oauth2
.opaqueToken(opaque -> opaque
.introspector(new NimbusOpaqueTokenIntrospector(
"https://auth.example.com/oauth2/introspect",
"client-id", "client-secret"
))
)
)
.build();
}
JWT vs Opaque Tokens
| JWT | Opaque Token | |
|---|---|---|
| Validation | Local (no network call) | Remote introspection call |
| Revocation | Hard (wait for expiry) | Instant |
| Payload | Self-contained claims | Server-side lookup |
| Performance | Better (no roundtrip) | Extra latency per request |
| Best for | Microservices, high throughput | High-security, immediate revoke |
OAuth2 Login (Social Login)
Authorization Code Flow
%%{init: {'theme': 'base', 'themeVariables': {'fontSize': '13px', 'fontFamily': 'Inter, -apple-system, sans-serif'}, 'flowchart': {'nodeSpacing': 30, 'rankSpacing': 50, 'padding': 12, 'curve': 'basis'}, 'sequence': {'actorMargin': 60, 'messageMargin': 40}, 'class': {'padding': 12}}}%%
flowchart LR
USER["User"] -->|1 Login Click| APP["Spring App"]
APP -->|2 Redirect| AUTH["Authorization<br/>Server"]
AUTH -->|3 Login Page| USER
USER -->|4 Credentials| AUTH
AUTH -->|5 Auth Code| APP
APP -->|6 Exchange Code<br/>for Token| AUTH
AUTH -->|7 Access Token<br/>+ ID Token| APP
APP -->|8 Fetch User Info| UINFO["UserInfo<br/>Endpoint"]
UINFO -->|9 User Details| APP
APP -->|10 Session| USER
style USER fill:#DBEAFE,stroke:#93C5FD,stroke-width:2px,color:#1E40AF
style APP fill:#D1FAE5,stroke:#6EE7B7,stroke-width:2px,color:#065F46
style AUTH fill:#FEF3C7,stroke:#FCD34D,stroke-width:2px,color:#92400E
style UINFO fill:#FFFBEB,stroke:#FCD34D,stroke-width:2px,color:#92400E Provider Configuration (Google & GitHub)
spring:
security:
oauth2:
client:
registration:
google:
client-id: ${GOOGLE_CLIENT_ID}
client-secret: ${GOOGLE_CLIENT_SECRET}
scope: openid, profile, email
github:
client-id: ${GITHUB_CLIENT_ID}
client-secret: ${GITHUB_CLIENT_SECRET}
scope: read:user, user:email
provider:
# Google and GitHub are auto-configured — custom providers need explicit config:
custom-idp:
authorization-uri: https://idp.example.com/authorize
token-uri: https://idp.example.com/token
user-info-uri: https://idp.example.com/userinfo
user-name-attribute: sub
Security Config for OAuth2 Login
@Configuration
@EnableWebSecurity
public class OAuth2LoginConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
return http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/", "/login", "/error").permitAll()
.anyRequest().authenticated()
)
.oauth2Login(oauth2 -> oauth2
.loginPage("/login")
.defaultSuccessUrl("/dashboard", true)
.userInfoEndpoint(userInfo -> userInfo
.userService(customOAuth2UserService())
.oidcUserService(customOidcUserService())
)
)
.build();
}
}
Custom OAuth2UserService
Map external provider user info into your domain model:
@Service
public class CustomOAuth2UserService extends DefaultOAuth2UserService {
@Autowired
private UserRepository userRepository;
@Override
public OAuth2User loadUser(OAuth2UserRequest request) throws OAuth2AuthenticationException {
OAuth2User oauth2User = super.loadUser(request);
String provider = request.getClientRegistration().getRegistrationId(); // "google", "github"
String providerId = oauth2User.getAttribute("sub"); // or "id" for GitHub
String email = oauth2User.getAttribute("email");
String name = oauth2User.getAttribute("name");
// Upsert user in local DB
User user = userRepository.findByProviderAndProviderId(provider, providerId)
.orElseGet(() -> User.builder()
.provider(provider)
.providerId(providerId)
.email(email)
.name(name)
.role("USER")
.build()
);
user.setLastLogin(Instant.now());
userRepository.save(user);
// Return with local roles as authorities
Set<GrantedAuthority> authorities = Set.of(
new SimpleGrantedAuthority("ROLE_" + user.getRole())
);
return new DefaultOAuth2User(authorities, oauth2User.getAttributes(), "email");
}
}
User Info Endpoint Customization
@Bean
public OAuth2UserService<OidcUserRequest, OidcUser> customOidcUserService() {
OidcUserService delegate = new OidcUserService();
return request -> {
OidcUser oidcUser = delegate.loadUser(request);
// Add custom authorities based on org membership, email domain, etc.
Set<GrantedAuthority> authorities = new HashSet<>(oidcUser.getAuthorities());
String email = oidcUser.getEmail();
if (email != null && email.endsWith("@mycompany.com")) {
authorities.add(new SimpleGrantedAuthority("ROLE_INTERNAL"));
}
return new DefaultOidcUser(authorities, oidcUser.getIdToken(), oidcUser.getUserInfo());
};
}
Comparison: @PreAuthorize vs @Secured vs URL-based Security
| Feature | @PreAuthorize | @Secured | URL-based (requestMatchers) |
|---|---|---|---|
| SpEL Support | Full SpEL | None | None |
| Granularity | Method-level | Method-level | URL pattern-level |
| Access to method args | Yes (#paramName) | No | No |
| Access to return value | Via @PostAuthorize | No | No |
| Custom expressions | Yes (bean refs, custom evaluators) | No | No |
| Role checks | hasRole(...) | @Secured("ROLE_X") | .hasRole("X") |
| Collection filtering | @PreFilter/@PostFilter | No | No |
| IDOR Protection | Yes (match arg to principal) | No | No |
| Performance | Slight AOP overhead | Slight AOP overhead | Filter-level (earliest) |
| Best for | Fine-grained domain authorization | Simple role gates | Broad URL patterns, public endpoints |
Defense in Depth — Use Both
Quick Recall
| Concept | Key Point |
|---|---|
@EnableMethodSecurity | Replaces deprecated @EnableGlobalMethodSecurity; enables @PreAuthorize by default |
@PreAuthorize | SpEL before execution; prevents IDOR |
@PostAuthorize | SpEL after execution; checks return value |
@PreFilter/@PostFilter | Silently filters collections |
@Secured | Simple, no SpEL, role-only |
PermissionEvaluator | Domain-specific hasPermission() logic |
| Resource Server JWT | Stateless; validates token locally via JWKS |
| Opaque Token | Calls introspection endpoint; supports instant revocation |
JwtAuthenticationConverter | Maps JWT claims to GrantedAuthority |
| OAuth2 Login | Authorization Code flow; creates session |
OAuth2UserService | Customizes user loading from provider |
| Audience validation | Custom OAuth2TokenValidator on JwtDecoder |
Interview Template
Tell me about Method Security vs URL-based security
Situation: "Our multi-tenant SaaS had URL security only — authenticated() on /api/tenants/{id}/**."
Problem: "Any authenticated user could access other tenants' data by changing the URL path parameter — a classic IDOR vulnerability discovered in a pen test."
Solution: "We added @PreAuthorize annotations that verified #tenantId == authentication.principal.tenantId at the service layer. We also implemented a custom PermissionEvaluator for complex cross-entity checks."
Result: "Zero IDOR findings in subsequent pen tests. Method security caught 12 unauthorized access attempts in the first week of monitoring."
How do you configure a Resource Server with JWT?
Setup: "We use spring-boot-starter-oauth2-resource-server with issuer-uri pointing to our Keycloak instance."
Validation: "Spring auto-fetches the JWKS from the well-known endpoint. We add custom validators for audience and tenant claims."
Authority Mapping: "Keycloak puts roles in realm_access.roles, so we configure a custom JwtAuthenticationConverter that extracts these into ROLE_ prefixed authorities."
Result: "Stateless, horizontally scalable microservices. Token validation adds less than 1ms per request."
OAuth2 Login vs Resource Server — when to use which?
OAuth2 Login: "For server-rendered apps or BFF (Backend-for-Frontend) that need to log users in via browser redirects. Creates an HTTP session."
Resource Server: "For stateless APIs consumed by SPAs or other services that already have a token. No session, no login page."
Combination: "Our architecture uses OAuth2 Login in the BFF Gateway, which then forwards the JWT to downstream Resource Server microservices."