# HTMX Security Guide for Spring Boot

Complete security reference for HTMX + Spring Boot applications.

## Table of Contents

1. [CSRF Protection](#csrf-protection)
2. [Spring Security Integration](#spring-security-integration)
3. [Authentication](#authentication)
4. [Authorization](#authorization)
5. [Input Validation](#input-validation)
6. [XSS Prevention](#xss-prevention)
7. [Rate Limiting](#rate-limiting)
8. [Security Headers](#security-headers)

---

## CSRF Protection

### Enable CSRF for HTMX

Spring Security requires CSRF tokens for POST/PUT/DELETE requests.

**Configure CSRF in Security Config:**

```java
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .csrf()
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            .and()
            .authorizeRequests()
                .anyRequest().authenticated()
            .and()
            .formLogin();
    }
}
```

### Include CSRF Token in Templates

**Base Layout Template:**

```html
<!DOCTYPE html>
<html xmlns:th="http://www.thymeleaf.org">
  <head>
    <meta charset="UTF-8" />
    <title th:text="${title}">App Title</title>

    <!-- CSRF meta tags -->
    <meta name="_csrf" th:content="${_csrf.token}" />
    <meta name="_csrf_header" th:content="${_csrf.headerName}" />
  </head>
  <body>
    <!-- Content -->

    <script src="/js/htmx.min.js"></script>
    <script th:inline="javascript">
      // Configure HTMX to send CSRF token
      document.addEventListener("DOMContentLoaded", function () {
        document.body.addEventListener("htmx:configRequest", function (evt) {
          const token = document
            .querySelector('meta[name="_csrf"]')
            .getAttribute("content");
          const header = document
            .querySelector('meta[name="_csrf_header"]')
            .getAttribute("content");

          evt.detail.headers[header] = token;
        });
      });
    </script>
  </body>
</html>
```

### Form-based CSRF (Fallback)

For forms without JavaScript:

```html
<form hx-post="/users" hx-target="#results">
  <input
    type="hidden"
    th:name="${_csrf.parameterName}"
    th:value="${_csrf.token}"
  />
  <input name="name" required />
  <button type="submit">Submit</button>
</form>
```

### CSRF Exception Handling

Handle CSRF errors gracefully:

```java
@ControllerAdvice
public class CsrfErrorHandler {

    @ExceptionHandler(CsrfException.class)
    public Object handleCsrfError(CsrfException ex,
                                  HtmxRequest htmxRequest,
                                  Model model) {
        if (htmxRequest.isHtmxRequest()) {
            model.addAttribute("error", "Session expired. Please refresh.");
            return HtmxResponse.builder()
                .retarget("#error-container")
                .view("fragments/csrf-error")
                .build();
        }
        return "error/csrf";
    }
}
```

**CSRF Error Fragment:**

```html
<!-- fragments/csrf-error.html -->
<div class="alert alert-warning">
  <p th:text="${error}">Session expired. Please refresh.</p>
  <button onclick="window.location.reload()">Refresh Page</button>
</div>
```

---

## Spring Security Integration

### Security Configuration

```java
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .authorizeRequests()
                .antMatchers("/", "/public/**").permitAll()
                .antMatchers("/admin/**").hasRole("ADMIN")
                .antMatchers("/api/**").hasAnyRole("USER", "ADMIN")
                .anyRequest().authenticated()
            .and()
            .formLogin()
                .loginPage("/login")
                .defaultSuccessUrl("/dashboard")
                .permitAll()
            .and()
            .logout()
                .logoutSuccessUrl("/")
                .permitAll()
            .and()
            .csrf()
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse());
    }

    @Override
    protected void configure(AuthenticationManagerBuilder auth)
            throws Exception {
        auth.inMemoryAuthentication()
            .withUser("user")
            .password(passwordEncoder().encode("password"))
            .roles("USER")
            .and()
            .withUser("admin")
            .password(passwordEncoder().encode("admin"))
            .roles("ADMIN", "USER");
    }

    @Bean
    public PasswordEncoder passwordEncoder() {
        return new BCryptPasswordEncoder();
    }
}
```

### HTMX Login Form

```html
<!-- login.html -->
<form hx-post="/login" hx-target="#login-form" hx-swap="outerHTML">
  <input
    type="hidden"
    th:name="${_csrf.parameterName}"
    th:value="${_csrf.token}"
  />

  <div class="form-group">
    <label for="username">Username</label>
    <input type="text" id="username" name="username" required />
  </div>

  <div class="form-group">
    <label for="password">Password</label>
    <input type="password" id="password" name="password" required />
  </div>

  <div th:if="${param.error}" class="alert alert-danger">
    Invalid username or password
  </div>

  <button type="submit">Login</button>
</form>
```

### Logout with HTMX

```html
<form hx-post="/logout" hx-confirm="Are you sure you want to logout?">
  <input
    type="hidden"
    th:name="${_csrf.parameterName}"
    th:value="${_csrf.token}"
  />
  <button type="submit">Logout</button>
</form>
```

---

## Authentication

### Check Authentication in Templates

```html
<html
  xmlns:th="http://www.thymeleaf.org"
  xmlns:sec="http://www.thymeleaf.org/extras/spring-security"
>
  <!-- Show for authenticated users -->
  <div sec:authorize="isAuthenticated()">
    <p>Welcome, <span th:text="${#authentication.name}">User</span>!</p>
    <button hx-get="/profile" hx-target="#content">My Profile</button>
  </div>

  <!-- Show for anonymous users -->
  <div sec:authorize="!isAuthenticated()">
    <a href="/login">Please login</a>
  </div>
</html>
```

### Access User Info in Controllers

```java
@HxRequest
@GetMapping("/profile")
public String profile(@AuthenticationPrincipal UserDetails user,
                      Model model) {
    model.addAttribute("username", user.getUsername());
    model.addAttribute("authorities", user.getAuthorities());
    return "fragments/profile";
}

// Or using SecurityContextHolder
@HxRequest
@GetMapping("/profile")
public String profile(Model model) {
    Authentication auth = SecurityContextHolder.getContext()
        .getAuthentication();
    String username = auth.getName();
    model.addAttribute("username", username);
    return "fragments/profile";
}
```

### Require Authentication on Endpoints

```java
@Controller
@RequestMapping("/users")
public class UserController {

    @HxRequest
    @PreAuthorize("isAuthenticated()")
    @GetMapping
    public String listUsers(Model model) {
        model.addAttribute("users", userService.findAll());
        return "fragments/user-list";
    }

    @HxRequest
    @PreAuthorize("hasRole('ADMIN')")
    @DeleteMapping("/{id}")
    public HtmxResponse deleteUser(@PathVariable Long id) {
        userService.delete(id);
        return HtmxResponse.builder()
            .trigger("user-deleted")
            .build();
    }
}
```

### Handle 401 Unauthorized

```java
@ControllerAdvice
public class AuthenticationErrorHandler {

    @ExceptionHandler(AccessDeniedException.class)
    public Object handleAccessDenied(AccessDeniedException ex,
                                     HtmxRequest htmxRequest,
                                     Model model) {
        if (htmxRequest.isHtmxRequest()) {
            model.addAttribute("error", "You are not authorized.");
            return HtmxResponse.builder()
                .retarget("#error-container")
                .view("fragments/unauthorized")
                .build();
        }
        return "error/403";
    }
}
```

---

## Authorization

### Role-Based Access Control

**Template (Thymeleaf):**

```html
<!-- Show only for ADMIN role -->
<div sec:authorize="hasRole('ADMIN')">
  <button
    hx-delete="/users/123"
    hx-target="closest tr"
    hx-confirm="Delete user?"
  >
    Delete User
  </button>
</div>

<!-- Show for USER or ADMIN -->
<div sec:authorize="hasAnyRole('USER', 'ADMIN')">
  <button hx-get="/data" hx-target="#results">Load Data</button>
</div>

<!-- Show for specific authority -->
<div sec:authorize="hasAuthority('USER_WRITE')">
  <button hx-post="/users" hx-target="#users">Create User</button>
</div>
```

**Controller:**

```java
@HxRequest
@PreAuthorize("hasRole('ADMIN')")
@DeleteMapping("/users/{id}")
public HtmxResponse deleteUser(@PathVariable Long id) {
    userService.delete(id);
    return HtmxResponse.builder()
        .trigger("user-deleted")
        .build();
}

@HxRequest
@PreAuthorize("hasAuthority('USER_READ')")
@GetMapping("/users")
public String listUsers(Model model) {
    model.addAttribute("users", userService.findAll());
    return "fragments/user-list";
}
```

### Resource-Based Authorization

Check ownership before allowing actions:

```java
@HxRequest
@PutMapping("/posts/{id}")
public HtmxResponse updatePost(@PathVariable Long id,
                               @Valid PostForm form,
                               @AuthenticationPrincipal UserDetails user,
                               Model model) {
    Post post = postService.findById(id);

    // Check ownership
    if (!post.getAuthor().getUsername().equals(user.getUsername())) {
        throw new AccessDeniedException("Not your post");
    }

    Post updated = postService.update(id, form);
    model.addAttribute("post", updated);

    return HtmxResponse.builder()
        .view("fragments/post-card")
        .trigger("post-updated")
        .build();
}
```

### Custom Authorization Logic

```java
@Service
public class PostSecurityService {

    public boolean canEdit(Long postId, String username) {
        Post post = postService.findById(postId);
        return post.getAuthor().getUsername().equals(username);
    }

    public boolean canDelete(Long postId, String username) {
        Post post = postService.findById(postId);
        return post.getAuthor().getUsername().equals(username)
            || hasRole(username, "ADMIN");
    }

    private boolean hasRole(String username, String role) {
        User user = userService.findByUsername(username);
        return user.getRoles().contains(role);
    }
}
```

```java
@HxRequest
@PreAuthorize("@postSecurityService.canEdit(#id, authentication.name)")
@PutMapping("/posts/{id}")
public HtmxResponse updatePost(@PathVariable Long id,
                               @Valid PostForm form) {
    Post updated = postService.update(id, form);
    return HtmxResponse.builder()
        .view(new ModelAndView("fragments/post-card",
              Map.of("post", updated)))
        .trigger("post-updated")
        .build();
}
```

---

## Input Validation

### Bean Validation

```java
public class UserForm {

    @NotBlank(message = "Name is required")
    @Size(min = 2, max = 100, message = "Name must be 2-100 characters")
    private String name;

    @NotBlank(message = "Email is required")
    @Email(message = "Invalid email format")
    private String email;

    @Size(min = 8, message = "Password must be at least 8 characters")
    @Pattern(regexp = "^(?=.*[A-Za-z])(?=.*\\d).+$",
             message = "Password must contain letters and numbers")
    private String password;

    // Getters and setters
}
```

### Controller Validation

```java
@HxRequest
@PostMapping("/users")
public String createUser(@Valid @ModelAttribute UserForm form,
                        BindingResult result,
                        Model model) {
    // Check validation errors
    if (result.hasErrors()) {
        model.addAttribute("user", form);
        return "fragments/user-form"; // Re-render with errors
    }

    // Custom business validation
    if (userService.emailExists(form.getEmail())) {
        result.rejectValue("email", "email.exists",
                          "Email already registered");
        model.addAttribute("user", form);
        return "fragments/user-form";
    }

    User user = userService.create(form);
    model.addAttribute("user", user);
    return "fragments/user-card";
}
```

### Display Validation Errors

```html
<form
  hx-post="/users"
  hx-target="#form-container"
  hx-swap="outerHTML"
  th:object="${user}"
>
  <div class="form-group">
    <label for="name">Name</label>
    <input
      type="text"
      id="name"
      th:field="*{name}"
      th:classappend="${#fields.hasErrors('name')} ? 'is-invalid' : ''"
    />
    <div th:if="${#fields.hasErrors('name')}" class="invalid-feedback">
      <span th:each="err : ${#fields.errors('name')}" th:text="${err}">
        Error message
      </span>
    </div>
  </div>

  <div class="form-group">
    <label for="email">Email</label>
    <input
      type="email"
      id="email"
      th:field="*{email}"
      th:classappend="${#fields.hasErrors('email')} ? 'is-invalid' : ''"
    />
    <div th:if="${#fields.hasErrors('email')}" class="invalid-feedback">
      <span th:each="err : ${#fields.errors('email')}" th:text="${err}">
        Error message
      </span>
    </div>
  </div>

  <button type="submit">Create User</button>
</form>
```

### Sanitize User Input

```java
@Service
public class InputSanitizer {

    public String sanitizeHtml(String input) {
        if (input == null) return null;
        return Jsoup.clean(input, Whitelist.basic());
    }

    public String sanitizeText(String input) {
        if (input == null) return null;
        return input.replaceAll("[<>\"']", "");
    }
}
```

```java
@HxRequest
@PostMapping("/comments")
public HtmxResponse createComment(@Valid CommentForm form, Model model) {
    // Sanitize input
    String sanitized = inputSanitizer.sanitizeHtml(form.getContent());
    form.setContent(sanitized);

    Comment comment = commentService.create(form);
    model.addAttribute("comment", comment);

    return HtmxResponse.builder()
        .view("fragments/comment")
        .trigger("comment-created")
        .build();
}
```

---

## XSS Prevention

### Always Use th:text (Auto-Escaping)

```html
<!-- GOOD: Escaped output -->
<p th:text="${user.bio}">User bio</p>

<!-- BAD: Unescaped (use only for trusted HTML) -->
<div th:utext="${user.bio}">User bio</div>
```

### Sanitize Rich Text Input

Use Jsoup or OWASP HTML Sanitizer:

```xml
<dependency>
    <groupId>org.jsoup</groupId>
    <artifactId>jsoup</artifactId>
    <version>1.15.3</version>
</dependency>
```

```java
@Service
public class HtmlSanitizer {

    public String sanitize(String html) {
        if (html == null) return null;

        // Allow only safe HTML tags
        Whitelist whitelist = Whitelist.relaxed()
            .addTags("h1", "h2", "h3")
            .addAttributes("a", "href", "title")
            .addProtocols("a", "href", "http", "https");

        return Jsoup.clean(html, whitelist);
    }
}
```

```java
@HxRequest
@PostMapping("/posts")
public HtmxResponse createPost(@Valid PostForm form, Model model) {
    // Sanitize rich text content
    String sanitized = htmlSanitizer.sanitize(form.getContent());
    form.setContent(sanitized);

    Post post = postService.create(form);
    model.addAttribute("post", post);

    return HtmxResponse.builder()
        .view("fragments/post-card")
        .trigger("post-created")
        .build();
}
```

### Content Security Policy (CSP)

```java
@Configuration
public class SecurityHeadersConfig {

    @Bean
    public FilterRegistrationBean<CspFilter> cspFilter() {
        FilterRegistrationBean<CspFilter> registration =
            new FilterRegistrationBean<>();
        registration.setFilter(new CspFilter());
        registration.addUrlPatterns("/*");
        return registration;
    }

    public class CspFilter implements Filter {
        @Override
        public void doFilter(ServletRequest request,
                           ServletResponse response,
                           FilterChain chain)
                throws IOException, ServletException {
            HttpServletResponse httpResponse = (HttpServletResponse) response;

            String csp = "default-src 'self'; " +
                        "script-src 'self' https://unpkg.com; " +
                        "style-src 'self' 'unsafe-inline'; " +
                        "img-src 'self' data: https:;";

            httpResponse.setHeader("Content-Security-Policy", csp);
            chain.doFilter(request, response);
        }
    }
}
```

---

## Rate Limiting

### Bucket4j Rate Limiting

```xml
<dependency>
    <groupId>com.github.vladimir-bukhtoyarov</groupId>
    <artifactId>bucket4j-core</artifactId>
    <version>7.6.0</version>
</dependency>
```

```java
@Component
public class RateLimitInterceptor implements HandlerInterceptor {

    private final Map<String, Bucket> cache = new ConcurrentHashMap<>();

    @Override
    public boolean preHandle(HttpServletRequest request,
                            HttpServletResponse response,
                            Object handler) throws Exception {
        String key = getClientId(request);
        Bucket bucket = resolveBucket(key);

        if (bucket.tryConsume(1)) {
            return true;
        }

        response.setStatus(429); // Too Many Requests
        response.getWriter().write("Rate limit exceeded");
        return false;
    }

    private Bucket resolveBucket(String key) {
        return cache.computeIfAbsent(key, k -> {
            Bandwidth limit = Bandwidth.classic(10, Refill.intervally(10,
                             Duration.ofMinutes(1)));
            return Bucket.builder()
                .addLimit(limit)
                .build();
        });
    }

    private String getClientId(HttpServletRequest request) {
        // Use IP address or user ID
        return request.getRemoteAddr();
    }
}
```

```java
@Configuration
public class WebConfig implements WebMvcConfigurer {

    @Autowired
    private RateLimitInterceptor rateLimitInterceptor;

    @Override
    public void addInterceptors(InterceptorRegistry registry) {
        registry.addInterceptor(rateLimitInterceptor)
            .addPathPatterns("/api/**");
    }
}
```

### Handle Rate Limit Errors in HTMX

```java
@ControllerAdvice
public class RateLimitErrorHandler {

    @ExceptionHandler(RateLimitExceededException.class)
    @ResponseStatus(HttpStatus.TOO_MANY_REQUESTS)
    public Object handleRateLimit(RateLimitExceededException ex,
                                  HtmxRequest htmxRequest,
                                  Model model) {
        if (htmxRequest.isHtmxRequest()) {
            model.addAttribute("error", "Too many requests. Please try later.");
            return HtmxResponse.builder()
                .retarget("#error-container")
                .view("fragments/rate-limit-error")
                .build();
        }
        return "error/429";
    }
}
```

---

## Security Headers

### Configure Security Headers

```java
@Configuration
@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {

    @Override
    protected void configure(HttpSecurity http) throws Exception {
        http
            .headers()
                .contentSecurityPolicy("default-src 'self'")
            .and()
                .referrerPolicy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN)
            .and()
                .permissionsPolicy(policy -> policy.policy("geolocation=(self)"))
            .and()
                .frameOptions().deny()
                .xssProtection().block(true)
                .contentTypeOptions()
            .and()
            .and()
            .csrf()
                .csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
            .and()
            .authorizeRequests()
                .anyRequest().authenticated();
    }
}
```

### Custom Security Headers Filter

```java
@Component
public class SecurityHeadersFilter implements Filter {

    @Override
    public void doFilter(ServletRequest request,
                        ServletResponse response,
                        FilterChain chain)
            throws IOException, ServletException {
        HttpServletResponse httpResponse = (HttpServletResponse) response;

        // Prevent clickjacking
        httpResponse.setHeader("X-Frame-Options", "DENY");

        // XSS Protection
        httpResponse.setHeader("X-XSS-Protection", "1; mode=block");

        // Content Type sniffing
        httpResponse.setHeader("X-Content-Type-Options", "nosniff");

        // Referrer Policy
        httpResponse.setHeader("Referrer-Policy", "strict-origin-when-cross-origin");

        // HSTS (HTTPS only)
        httpResponse.setHeader("Strict-Transport-Security",
                              "max-age=31536000; includeSubDomains");

        chain.doFilter(request, response);
    }
}
```

---

## Best Practices

1. **Always enable CSRF protection** - Required for POST/PUT/DELETE
2. **Send CSRF token in all HTMX requests** - Use `htmx:configRequest` event
3. **Use @PreAuthorize for method security** - Enforce authorization at controller level
4. **Validate all user input** - Server-side validation is mandatory
5. **Sanitize rich text content** - Use Jsoup or OWASP sanitizer
6. **Always use th:text for output** - Prevents XSS attacks
7. **Implement rate limiting** - Prevent abuse and DoS
8. **Set security headers** - CSP, X-Frame-Options, etc.
9. **Use HTTPS in production** - Enable HSTS
10. **Log security events** - Track authentication failures, access denials
