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.