Clickjacking (UI redress attack) is a malicious technique that tricks users into clicking something different from what they perceive. Proper HTTP headers are crucial for protecting against these attacks.
Understanding Clickjacking Protection Headers
Primary Headers for Clickjacking Protection
- X-Frame-Options: Legacy but widely supported
- Content-Security-Policy (frame-ancestors): Modern standard
- Clear-Site-Data: Additional cleanup protection
Spring Security Implementation
Example 1: Basic Spring Security Configuration
@Configuration @EnableWebSecurity public class SecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http // Clickjacking protection .headers(headers -> headers .frameOptions(frameOptions -> frameOptions .deny() // or .sameOrigin() ) .contentSecurityPolicy(csp -> csp .policyDirectives("frame-ancestors 'none'") ) .xssProtection(xss -> xss .headerValue(XXssProtectionHeaderWriter.HeaderValue.ENABLED_MODE_BLOCK) ) ) .authorizeHttpRequests(authz -> authz .anyRequest().permitAll() ); return http.build(); } } Example 2: Advanced Header Configuration
@Configuration @EnableWebSecurity public class AdvancedSecurityConfig { @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http .headers(headers -> headers // Frame options for clickjacking protection .frameOptions(frameOptions -> frameOptions .sameOrigin() // Allow same origin framing ) // Content Security Policy .contentSecurityPolicy(csp -> csp .policyDirectives("default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://trusted.cdn.com; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "frame-ancestors 'self' https://trusted.example.com; " + "form-action 'self'") ) // X-Content-Type-Options .contentTypeOptions(contentType -> {}) // Strict Transport Security .httpStrictTransportSecurity(hsts -> hsts .includeSubDomains(true) .preload(true) .maxAgeInSeconds(31536000) // 1 year ) // Referrer Policy .referrerPolicy(referrer -> referrer .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN) ) // Permissions Policy .permissionsPolicy(permissions -> permissions .policy("camera=(), microphone=(), location=(), payment=()") ) ); return http.build(); } } Manual Servlet Filter Implementation
Example 3: Custom Security Headers Filter
@Component public class SecurityHeadersFilter implements Filter { private static final String CSP_POLICY = "default-src 'self'; " + "script-src 'self' 'unsafe-inline' https://trusted.cdn.com; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "font-src 'self'; " + "connect-src 'self'; " + "frame-ancestors 'none'; " + "form-action 'self'; " + "base-uri 'self';"; @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletResponse httpResponse = (HttpServletResponse) response; // Clickjacking protection headers httpResponse.setHeader("X-Frame-Options", "DENY"); httpResponse.setHeader("Content-Security-Policy", CSP_POLICY); // Additional security headers httpResponse.setHeader("X-Content-Type-Options", "nosniff"); httpResponse.setHeader("X-XSS-Protection", "1; mode=block"); httpResponse.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); httpResponse.setHeader("Permissions-Policy", "camera=(), microphone=(), location=(), payment=()"); // Cache control for sensitive pages if (isSensitivePage((HttpServletRequest) request)) { httpResponse.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); httpResponse.setHeader("Pragma", "no-cache"); httpResponse.setDateHeader("Expires", 0); } chain.doFilter(request, response); } private boolean isSensitivePage(HttpServletRequest request) { String path = request.getRequestURI(); return path.contains("/admin") || path.contains("/account") || path.contains("/payment"); } @Override public void init(FilterConfig filterConfig) throws ServletException { // Initialization if needed } @Override public void destroy() { // Cleanup if needed } } Example 4: Dynamic CSP Header Filter
@Component public class DynamicCspFilter implements Filter { private final CspPolicyProvider cspPolicyProvider; public DynamicCspFilter(CspPolicyProvider cspPolicyProvider) { this.cspPolicyProvider = cspPolicyProvider; } @Override public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpRequest = (HttpServletRequest) request; HttpServletResponse httpResponse = (HttpServletResponse) response; // Generate dynamic CSP based on request String cspPolicy = cspPolicyProvider.generatePolicy(httpRequest); String nonce = generateNonce(); // Replace nonce placeholder in CSP cspPolicy = cspPolicy.replace("'nonce-{nonce}'", "'nonce-" + nonce + "'"); httpResponse.setHeader("Content-Security-Policy", cspPolicy); httpResponse.setHeader("X-Frame-Options", "DENY"); // Store nonce in request for use in templates httpRequest.setAttribute("cspNonce", nonce); chain.doFilter(request, response); } private String generateNonce() { byte[] nonceBytes = new byte[16]; new SecureRandom().nextBytes(nonceBytes); return Base64.getEncoder().encodeToString(nonceBytes); } } @Service class CspPolicyProvider { public String generatePolicy(HttpServletRequest request) { String path = request.getRequestURI(); // Different policies for different sections if (path.startsWith("/admin")) { return adminPolicy(); } else if (path.startsWith("/public")) { return publicPolicy(); } else if (path.startsWith("/api")) { return apiPolicy(); } else { return defaultPolicy(); } } private String defaultPolicy() { return "default-src 'self'; " + "script-src 'self' 'nonce-{nonce}'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data: https:; " + "font-src 'self'; " + "connect-src 'self'; " + "frame-ancestors 'none'; " + "form-action 'self'; " + "base-uri 'self';"; } private String adminPolicy() { return "default-src 'self'; " + "script-src 'self' 'nonce-{nonce}'; " + "style-src 'self' 'unsafe-inline'; " + "img-src 'self' data:; " + "font-src 'self'; " + "connect-src 'self'; " + "frame-ancestors 'none'; " + "form-action 'self'; " + "base-uri 'self';"; } private String publicPolicy() { return "default-src 'self' https://cdn.example.com; " + "script-src 'self' https://cdn.example.com 'nonce-{nonce}'; " + "style-src 'self' https://cdn.example.com 'unsafe-inline'; " + "img-src 'self' data: https:; " + "font-src 'self' https://fonts.googleapis.com; " + "connect-src 'self'; " + "frame-ancestors 'self' https://embed.example.com; " + "form-action 'self'; " + "base-uri 'self';"; } private String apiPolicy() { return "default-src 'none'; " + "frame-ancestors 'none';"; } } Spring Boot Configuration Properties
Example 5: Application Properties Configuration
# application.yml server: servlet: session: cookie: secure: true http-only: true same-site: strict spring: security: headers: content-security-policy: "default-src 'self'; frame-ancestors 'none';" frame-options: DENY content-type: true xss-protection: true hsts: include-subdomains: true preload: true max-age: 31536000 # Custom security properties app: security: csp: enabled: true report-only: false policy: "default-src 'self'; script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline'; frame-ancestors 'none'" allowed-frame-ancestors: - "https://trusted.example.com" - "https://embed.partner.com"
Controller-Level Configuration
Example 6: Fine-Grained Header Control
@RestController public class SecureController { @GetMapping("/public/content") public ResponseEntity<String> publicContent() { return ResponseEntity.ok() .header("X-Frame-Options", "SAMEORIGIN") .header("Content-Security-Policy", "frame-ancestors 'self'") .body("Public content that can be framed by same origin"); } @GetMapping("/sensitive/account") public ResponseEntity<String> sensitiveContent() { return ResponseEntity.ok() .header("X-Frame-Options", "DENY") .header("Content-Security-Policy", "frame-ancestors 'none'") .header("Cache-Control", "no-store, no-cache, must-revalidate") .body("Sensitive account information"); } @GetMapping("/embed/widget") public ResponseEntity<String> embeddableWidget() { String allowedOrigins = "https://trusted.example.com https://partner.com"; return ResponseEntity.ok() .header("X-Frame-Options", "ALLOW-FROM https://trusted.example.com") .header("Content-Security-Policy", "frame-ancestors " + allowedOrigins) .body("Embeddable widget"); } @GetMapping("/api/data") public ResponseEntity<Map<String, Object>> apiData() { Map<String, Object> data = Map.of("message", "API response"); return ResponseEntity.ok() .header("X-Frame-Options", "DENY") .header("Content-Security-Policy", "default-src 'none'; frame-ancestors 'none'") .body(data); } } Example 7: Annotation-Based Header Configuration
@Target(ElementType.METHOD) @Retention(RetentionPolicy.RUNTIME) public @interface SecurityHeaders { FrameOptions frameOptions() default FrameOptions.DENY; String[] frameAncestors() default {}; boolean noCache() default false; enum FrameOptions { DENY, SAMEORIGIN, ALLOW_FROM } } @Aspect @Component public class SecurityHeadersAspect { @Around("@annotation(securityHeaders)") public Object applySecurityHeaders(ProceedingJoinPoint joinPoint, SecurityHeaders securityHeaders) throws Throwable { if (joinPoint.getTarget() instanceof HttpServletResponse) { HttpServletResponse response = (HttpServletResponse) joinPoint.getArgs()[1]; applyHeaders(response, securityHeaders); } return joinPoint.proceed(); } private void applyHeaders(HttpServletResponse response, SecurityHeaders securityHeaders) { // Apply frame options switch (securityHeaders.frameOptions()) { case DENY: response.setHeader("X-Frame-Options", "DENY"); break; case SAMEORIGIN: response.setHeader("X-Frame-Options", "SAMEORIGIN"); break; case ALLOW_FROM: if (securityHeaders.frameAncestors().length > 0) { response.setHeader("X-Frame-Options", "ALLOW-FROM " + securityHeaders.frameAncestors()[0]); } break; } // Apply CSP frame-ancestors if (securityHeaders.frameAncestors().length > 0) { String frameAncestors = String.join(" ", securityHeaders.frameAncestors()); response.setHeader("Content-Security-Policy", "frame-ancestors " + frameAncestors); } else { response.setHeader("Content-Security-Policy", "frame-ancestors 'none'"); } // Apply cache control if (securityHeaders.noCache()) { response.setHeader("Cache-Control", "no-store, no-cache, must-revalidate"); response.setHeader("Pragma", "no-cache"); } } } @RestController public class AnnotatedController { @GetMapping("/admin/dashboard") @SecurityHeaders(frameOptions = SecurityHeaders.FrameOptions.DENY, noCache = true) public String adminDashboard() { return "Admin dashboard"; } @GetMapping("/public/widget") @SecurityHeaders( frameOptions = SecurityHeaders.FrameOptions.ALLOW_FROM, frameAncestors = {"https://trusted.example.com", "https://partner.com"} ) public String publicWidget() { return "Public widget"; } } Testing Clickjacking Protection
Example 8: Security Headers Testing
@SpringBootTest @AutoConfigureTestDatabase class ClickjackingProtectionTest { @Autowired private TestRestTemplate restTemplate; @Test void testClickjackingHeadersOnPublicEndpoint() { ResponseEntity<String> response = restTemplate.getForEntity("/public/content", String.class); HttpHeaders headers = response.getHeaders(); // Test X-Frame-Options assertThat(headers.getFirst("X-Frame-Options")).isEqualTo("DENY"); // Test CSP frame-ancestors assertThat(headers.getFirst("Content-Security-Policy")) .contains("frame-ancestors"); // Test additional security headers assertThat(headers.getFirst("X-Content-Type-Options")).isEqualTo("nosniff"); assertThat(headers.getFirst("X-XSS-Protection")).isNotNull(); } @Test void testSensitiveEndpointHasStrictHeaders() { ResponseEntity<String> response = restTemplate.getForEntity("/admin/dashboard", String.class); HttpHeaders headers = response.getHeaders(); // Test strict frame options assertThat(headers.getFirst("X-Frame-Options")).isEqualTo("DENY"); assertThat(headers.getFirst("Content-Security-Policy")) .contains("frame-ancestors 'none'"); // Test no-cache headers assertThat(headers.getFirst("Cache-Control")) .contains("no-store"); assertThat(headers.getFirst("Pragma")).isEqualTo("no-cache"); } @Test void testEmbeddableWidgetAllowsFraming() { ResponseEntity<String> response = restTemplate.getForEntity("/embed/widget", String.class); HttpHeaders headers = response.getHeaders(); // Test specific frame ancestors assertThat(headers.getFirst("Content-Security-Policy")) .contains("frame-ancestors https://trusted.example.com https://partner.com"); } } @Component class SecurityHeadersValidator { public ValidationResult validateHeaders(HttpHeaders headers) { ValidationResult result = new ValidationResult(); // Check required security headers checkFrameOptions(headers, result); checkCsp(headers, result); checkAdditionalHeaders(headers, result); return result; } private void checkFrameOptions(HttpHeaders headers, ValidationResult result) { String frameOptions = headers.getFirst("X-Frame-Options"); if (frameOptions == null) { result.addIssue("MISSING", "X-Frame-Options header is missing"); } else if (!frameOptions.equals("DENY") && !frameOptions.equals("SAMEORIGIN")) { result.addIssue("WEAK", "X-Frame-Options value might be weak: " + frameOptions); } } private void checkCsp(HttpHeaders headers, ValidationResult result) { String csp = headers.getFirst("Content-Security-Policy"); if (csp == null) { result.addIssue("MISSING", "Content-Security-Policy header is missing"); } else if (!csp.contains("frame-ancestors")) { result.addIssue("WEAK", "CSP missing frame-ancestors directive"); } else if (csp.contains("frame-ancestors *")) { result.addIssue("INSECURE", "CSP allows framing from any origin"); } } private void checkAdditionalHeaders(HttpHeaders headers, ValidationResult result) { if (headers.getFirst("X-Content-Type-Options") == null) { result.addIssue("MISSING", "X-Content-Type-Options header is missing"); } if (headers.getFirst("X-XSS-Protection") == null) { result.addIssue("MISSING", "X-XSS-Protection header is missing"); } } @Data public static class ValidationResult { private List<SecurityIssue> issues = new ArrayList<>(); private boolean passed = true; public void addIssue(String severity, String description) { issues.add(new SecurityIssue(severity, description)); passed = false; } } @Data public static class SecurityIssue { private final String severity; private final String description; } } Best Practices and Recommendations
Security Configuration Best Practices
@Configuration public class SecurityBestPractices { @Bean public SecurityFilterChain bestPracticeFilterChain(HttpSecurity http) throws Exception { http .headers(headers -> headers // Always set both X-Frame-Options and CSP for maximum compatibility .frameOptions(frameOptions -> frameOptions.deny()) .contentSecurityPolicy(csp -> csp .policyDirectives(buildCspPolicy()) ) // Prevent MIME type sniffing .contentTypeOptions(ContentTypeOptionsConfig::disable) // Enable XSS protection .xssProtection(xss -> xss.block(true)) // HSTS for HTTPS sites .httpStrictTransportSecurity(hsts -> hsts .includeSubDomains(true) .preload(true) .maxAgeInSeconds(31536000) ) // Referrer policy .referrerPolicy(referrer -> referrer .policy(ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN) ) // Feature policy (now Permissions Policy) .permissionsPolicy(permissions -> permissions .policy("camera=(), microphone=(), geolocation=(), payment=()") ) ) // Additional security configurations .csrf(csrf -> csrf .ignoringRequestMatchers("/api/public/**") ) .sessionManagement(session -> session .sessionFixation().migrateSession() .maximumSessions(1) .maxSessionsPreventsLogin(true) ); return http.build(); } private String buildCspPolicy() { return String.join("; ", "default-src 'self'", "script-src 'self' 'unsafe-inline' https://trusted.cdn.com", "style-src 'self' 'unsafe-inline'", "img-src 'self' data: https:", "font-src 'self' https://fonts.gstatic.com", "connect-src 'self'", "frame-ancestors 'none'", "form-action 'self'", "base-uri 'self'", "object-src 'none'" ); } } Header Implementation Matrix
| Header | Purpose | Recommended Value | Browser Support |
|---|---|---|---|
| X-Frame-Options | Basic clickjacking protection | DENY or SAMEORIGIN | Widespread |
| Content-Security-Policy | Modern clickjacking protection | frame-ancestors 'none' | Modern browsers |
| X-Content-Type-Options | Prevent MIME sniffing | nosniff | Widespread |
| X-XSS-Protection | Basic XSS protection | 1; mode=block | Legacy browsers |
Conclusion
Clickjacking protection is essential for web application security. Key recommendations:
- Use Both Headers: Implement both
X-Frame-OptionsandContent-Security-Policyfor maximum compatibility - Default to DENY: Unless specifically required, prevent all framing
- Be Specific with Allowances: When allowing framing, specify exact origins
- Test Thoroughly: Regularly test headers with automated tools
- Monitor Reports: Use CSP report-uri to detect potential issues
Remember that security headers are just one layer of defense. Always implement proper authentication, authorization, and input validation alongside header-based protections.