Sessions, Cookies & Filters

Practical Filters

18 min Lesson 7 of 13

Practical Filters

In the previous lesson you learned what a Jakarta EE filter is. Now you will build four production-grade filters that you will reach for on almost every project: a request/response logger, an authentication guard, a GZIP compression filter, and a CORS handler. Each one demonstrates a different interaction pattern with the filter chain and shows you the trade-offs you need to reason about when deploying it.

1. Request/Response Logging Filter

A logging filter sits at the outermost ring of the chain. It records the incoming request, lets the chain run, then records the response. The key challenge is that HttpServletResponse does not let you read the body after it has been written to the client — you need a wrapper to capture it first.

import jakarta.servlet.*; import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.http.*; import java.io.*; import java.time.Instant; import java.util.logging.Logger; @WebFilter("/*") public class LoggingFilter implements Filter { private static final Logger LOG = Logger.getLogger(LoggingFilter.class.getName()); @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq = (HttpServletRequest) req; HttpServletResponse httpRes = (HttpServletResponse) res; long start = System.currentTimeMillis(); String requestLine = httpReq.getMethod() + " " + httpReq.getRequestURI(); // Wrap the response so we can capture status after chain runs StatusCapturingResponseWrapper wrapper = new StatusCapturingResponseWrapper(httpRes); try { chain.doFilter(httpReq, wrapper); } finally { long elapsed = System.currentTimeMillis() - start; LOG.info(String.format("[%s] %s -> %d (%d ms)", Instant.now(), requestLine, wrapper.getStatus(), elapsed)); } } } // Minimal wrapper — captures the status code written by the servlet class StatusCapturingResponseWrapper extends HttpServletResponseWrapper { private int status = 200; StatusCapturingResponseWrapper(HttpServletResponse response) { super(response); } @Override public void setStatus(int sc) { this.status = sc; super.setStatus(sc); } @Override public void sendError(int sc) throws IOException { this.status = sc; super.sendError(sc); } @Override public void sendError(int sc, String msg) throws IOException { this.status = sc; super.sendError(sc, msg); } public int getStatus() { return status; } }
Why wrap the response? HttpServletResponse is write-only once the output stream is open. A HttpServletResponseWrapper delegates all calls to the real response while letting you intercept specific methods. This is the standard Jakarta EE pattern whenever a filter needs to observe or modify the response after it leaves the servlet.

Notice the try/finally: logging must happen even when the servlet throws. Because this filter maps to /* it catches every request — static files included. In practice you would filter by content type or URI prefix to reduce noise.

2. Authentication Guard Filter

An auth filter intercepts protected URLs and redirects unauthenticated users to a login page. It must not call chain.doFilter for unauthorized requests — that is what makes it a guard.

import jakarta.servlet.*; import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.http.*; import java.io.IOException; @WebFilter(urlPatterns = {"/dashboard/*", "/api/admin/*"}) public class AuthenticationFilter implements Filter { private static final String LOGIN_PAGE = "/login"; @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq = (HttpServletRequest) req; HttpServletResponse httpRes = (HttpServletResponse) res; HttpSession session = httpReq.getSession(false); // false = do NOT create a session boolean loggedIn = session != null && session.getAttribute("user") != null; if (!loggedIn) { String target = httpReq.getRequestURI(); // API clients expect 401, browser clients get a redirect if (target.startsWith("/api/")) { httpRes.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authentication required"); } else { String loginUrl = httpReq.getContextPath() + LOGIN_PAGE + "?redirect=" + java.net.URLEncoder.encode(target, "UTF-8"); httpRes.sendRedirect(loginUrl); } return; // ← stop the chain; the servlet never runs } chain.doFilter(req, res); // authenticated — proceed normally } }
Always call getSession(false) in a guard filter. The default getSession() / getSession(true) creates a new session for every unauthenticated visitor, bloating your session store and making session fixation attacks easier. Pass false and treat a null return as "no session exists."

The filter treats API paths and browser paths differently — a smart pattern for applications that serve both. API clients cannot follow redirects; they need an HTTP status code their code can handle.

3. GZIP Compression Filter

Compression filters intercept the response output stream and wrap it with a GZIPOutputStream, transparently compressing everything the servlet writes. This can reduce response size by 60–80% for text payloads.

import jakarta.servlet.*; import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.http.*; import java.io.*; import java.util.zip.GZIPOutputStream; @WebFilter("/*") public class GzipFilter implements Filter { @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq = (HttpServletRequest) req; HttpServletResponse httpRes = (HttpServletResponse) res; String acceptEncoding = httpReq.getHeader("Accept-Encoding"); boolean clientAcceptsGzip = acceptEncoding != null && acceptEncoding.toLowerCase().contains("gzip"); if (!clientAcceptsGzip) { chain.doFilter(req, res); // client cannot decompress — pass through return; } httpRes.setHeader("Content-Encoding", "gzip"); httpRes.setHeader("Vary", "Accept-Encoding"); // tell caches this varies by encoding // Wrap the output stream with a gzip stream try (GzipResponseWrapper gzipWrapper = new GzipResponseWrapper(httpRes)) { chain.doFilter(httpReq, gzipWrapper); } } } // Wrapper that substitutes the servlet output stream with a GZIP stream class GzipResponseWrapper extends HttpServletResponseWrapper implements Closeable { private final ByteArrayOutputStream buffer = new ByteArrayOutputStream(); private final GZIPOutputStream gzip; private final PrintWriter writer; GzipResponseWrapper(HttpServletResponse response) throws IOException { super(response); gzip = new GZIPOutputStream(buffer); writer = new PrintWriter(new OutputStreamWriter(gzip, "UTF-8")); } @Override public PrintWriter getWriter() { return writer; } @Override public ServletOutputStream getOutputStream() { return new ServletOutputStream() { public void write(int b) throws IOException { gzip.write(b); } public void write(byte[] b, int off, int len) throws IOException { gzip.write(b, off, len); } public boolean isReady() { return true; } public void setWriteListener(WriteListener l) {} }; } @Override public void close() throws IOException { writer.flush(); gzip.finish(); byte[] compressed = buffer.toByteArray(); getResponse().setContentLength(compressed.length); getResponse().getOutputStream().write(compressed); } }
Set the Vary: Accept-Encoding header. Without it, a caching proxy can serve the compressed version to a client that requested plain text (or vice versa), causing garbled output. This one header tells every intermediary cache that the response content varies based on the client's Accept-Encoding header.

Do not compress already-compressed content types (JPEG, PNG, ZIP, video). Check getContentType() before wrapping, or only apply the filter to text/* and application/json URLs.

4. CORS Filter

Cross-Origin Resource Sharing headers must be set before the response body is written — and for preflight OPTIONS requests the response must be returned immediately without reaching the servlet at all. A filter is the canonical place to handle both requirements.

import jakarta.servlet.*; import jakarta.servlet.annotation.WebFilter; import jakarta.servlet.http.*; import java.io.IOException; import java.util.Set; @WebFilter("/api/*") public class CorsFilter implements Filter { // Allow specific origins — NEVER use "*" in production with credentials private static final Set<String> ALLOWED_ORIGINS = Set.of( "https://app.example.com", "https://admin.example.com" ); @Override public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) throws IOException, ServletException { HttpServletRequest httpReq = (HttpServletRequest) req; HttpServletResponse httpRes = (HttpServletResponse) res; String origin = httpReq.getHeader("Origin"); if (origin != null && ALLOWED_ORIGINS.contains(origin)) { httpRes.setHeader("Access-Control-Allow-Origin", origin); httpRes.setHeader("Access-Control-Allow-Credentials", "true"); httpRes.setHeader("Vary", "Origin"); // Only needed on preflight, but harmless on all responses httpRes.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, PATCH, OPTIONS"); httpRes.setHeader("Access-Control-Allow-Headers", "Authorization, Content-Type, X-Requested-With"); httpRes.setHeader("Access-Control-Max-Age", "3600"); // cache preflight 1 hour } // Preflight request: answer here, never pass to the servlet if ("OPTIONS".equalsIgnoreCase(httpReq.getMethod())) { httpRes.setStatus(HttpServletResponse.SC_NO_CONTENT); // 204 return; } chain.doFilter(req, res); } }
Never set Access-Control-Allow-Origin: * when Access-Control-Allow-Credentials: true is also set. Browsers reject that combination. Use an allowlist of specific origins and echo back the matched one. Also set Vary: Origin so caches do not serve one origin's response to another.

Filter Ordering

When multiple filters match the same URL the order they run in matters. With @WebFilter annotations the servlet container does not guarantee ordering. For deterministic order declare filters in web.xml using <filter-mapping> elements — their order in the XML is their execution order. In Spring Boot, set the @Order annotation or implement Ordered on a FilterRegistrationBean.

A sensible default ordering for the filters above:

  1. LoggingFilter — outermost, sees everything including auth failures
  2. CorsFilter — must set headers before any response is committed
  3. GzipFilter — wraps the response stream before the servlet writes
  4. AuthenticationFilter — innermost guard, closest to protected resources

Summary

Logging, authentication, compression, and CORS represent the four most common cross-cutting concerns you will need to implement as a servlet-based web developer. Each one follows the same structural pattern — wrap the request or response, conditionally call chain.doFilter, observe the result — but differs in where in the chain it belongs and whether it lets the chain proceed. Master these four and you have a template for every custom filter you will ever write.