Custom Interceptors

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Apache Struts 7.4.0 (released 2026-10-02) on Apache Tomcat 11.0.26, Jakarta Servlet 6.1 and Java 21 (Struts 7 needs Java 17 or later and a Servlet 6.0 or later container). Everything was verified on 2026-10-08: the facts against the Core Developers Guide, the Struts 7.4.0 Javadoc and the struts-default.xml of the 7.4.0 jar, and every example by building it into a Maven WAR (Struts from the struts2-bom 7.4.0) and calling it with curl on Tomcat 11.0.26, or by running it as a JUnit 6 test; the outputs shown are the outputs of those runs. An interceptor is the unit of reusable behaviour around an action: almost everything Struts does for you (exception mapping, parameter binding, validation, file upload, internationalisation) is an interceptor, and the same mechanism is open to application code. The built-in interceptors and the stacks are covered on the Interceptors page of this section; this page is about writing your own. The running scenario is the Bookshelf of the Struts half of this section: the books action that lists titles, now with an audit interceptor that records every invocation, a login-required interceptor that guards the state-changing remove method, and a tracing interceptor that makes the execution order visible.

What an Interceptor Is and When to Write One

An interceptor is a Java object that the framework calls around the execution of an action. The code before the call to invoke() runs before the action (and before every interceptor further down the stack); the code after it runs when the action and its result have completed. An interceptor can also decline to call invoke(): it then returns a result code of its own, the action never runs, and the framework renders that result. That single contract covers authentication, auditing, timing, transactions, rate limiting and request enrichment.

Struts has three other places where cross-cutting code can live, and choosing correctly keeps the interceptor small.

Table 1. Where to put cross-cutting code in a Struts application
Mechanism Sees Choose it when

Servlet Filter

The raw request and response of every URL, including static files and non-Struts servlets; knows nothing about actions, results or the ValueStack

The concern is about HTTP, not about actions: character encoding, compression, security headers, CORS, request logging for the whole site. See Servlet Fundamentals

Interceptor

The ActionInvocation: the action instance, the action and method names, the namespace, the ValueStack, the ActionContext (session, parameters, locale), the result code, the result about to be rendered

The concern is about actions: who may run this method, what result to return instead, what to put on the action before it runs, what to record about the outcome

PreResultListener

The same invocation, after the action method has returned and before the result executes

You must change the result code or the response after the action has decided but before the view is rendered (a response header, a flash message, a result override)

Action base class or the action itself

One action

The behaviour belongs to one action and will not be reused

The Interceptor Interface

The contract is the Interceptor interface. In Struts 7 it lives in org.apache.struts2.interceptor; the Core Developers Guide page on writing interceptors still prints the import as com.opensymphony.xwork2.interceptor.Interceptor, the package that the 6 to 7 migration guide says was renamed to org.apache.struts2 (the XWork code was merged into the Struts packages in 7.0). The 7.4.0 jar contains only the new names, which is what javap shows:

package org.apache.struts2.interceptor;

public interface Interceptor extends java.io.Serializable {
    void destroy();
    void init();
    String intercept(org.apache.struts2.ActionInvocation invocation) throws Exception;
}
Table 2. The three methods of Interceptor
Method When it runs and what to do there

init()

Once, after the framework has instantiated the interceptor and set its parameters, and before the first call to intercept. Allocate resources used by the interceptor here (a compiled pattern, a client, a cache).

intercept(ActionInvocation)

On every request that reaches the interceptor. Do the work; call invocation.invoke() to continue; return a result code. Like an action method it returns the name of a result that the framework forwards to.

destroy()

Once, when the application shuts down. Release what init() allocated.

The interface extends java.io.Serializable, so an interceptor must not hold non-serialisable state in instance fields unless it is marked transient; the interceptors that ship with Struts keep their configuration in plain fields.

The guide stresses one rule about invoke(): it returns after the result has been executed, so after your JSP has been rendered. That makes the code after invoke() the right place for cleanup that must cover the view (the classic open-session-in-view pattern, timing the whole request), and the wrong place for anything that must change the response, because the response is typically committed by then. Anything that must happen before the result runs belongs in a PreResultListener, described below.

AbstractInterceptor

Almost every interceptor extends AbstractInterceptor, which supplies empty init() and destroy() so that only intercept is left to write. In Struts 7.4 it also implements ConditionalInterceptor, which adds shouldIntercept(ActionInvocation) and a setDisabled(String) setter: an interceptor extending AbstractInterceptor can be switched off for one action with the parameter disabled (since Struts 6.1.0), described under Disabling an Interceptor for One Action.

public abstract class AbstractInterceptor implements ConditionalInterceptor {
    public void init();
    public void destroy();
    public abstract String intercept(ActionInvocation invocation) throws Exception;
    public void setDisabled(String disabled);
    public boolean shouldIntercept(ActionInvocation invocation);
}

A minimal interceptor therefore reads like the guide’s SimpleInterceptor, which sets a date on the action before it runs. Written for the Bookshelf (and with the Struts 7 package names) it looks like this: an interface on the action rather than a cast to a concrete action class keeps the interceptor reusable.

import java.time.Instant;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.interceptor.AbstractInterceptor;

public class RequestTimeInterceptor extends AbstractInterceptor {
    public interface RequestTimeAware {
        void setRequestTime(Instant requestTime);
    }

    @Override
    public String intercept(ActionInvocation invocation) throws Exception {
        if (invocation.getAction() instanceof RequestTimeAware aware) {
            aware.setRequestTime(Instant.now());
        }
        return invocation.invoke();
    }
}

The five facts to keep in mind about every intercept method are in the next table.

Table 3. What the return value of intercept means
What the interceptor does Effect

Returns invocation.invoke() (usually the last statement)

The request continues down the stack; the action runs when the last interceptor calls invoke(); the result code of the action travels back up through every intercept frame

Returns its own result code without calling invoke() (short-circuit)

Nothing below this interceptor runs, including the action and the other interceptors; the framework renders the named result. The interceptors above it still see the code on the way back

Calls invoke() and returns a different code

The action has run; the original code is replaced before it reaches the framework (the result is looked up for the new code)

Throws an exception

The interceptors above it see it as an exception; the exception interceptor of the default stack maps it through exception-mapping, otherwise it reaches the container’s error page

Calls invoke() twice

Not supported: the invocation is a one-way chain. Call it at most once

The ActionInvocation

Every interceptor receives the same ActionInvocation for the whole request. It is the interceptor’s window into the framework, and the table lists what it exposes in 7.4.0 (from the Javadoc and javap).

Table 4. The methods of ActionInvocation that interceptors use
Method Use

invoke()

Runs the next interceptor, or the action when none is left, and (when the proxy executes results) the result; returns the result code

getAction()

The action instance of this request; use instanceof with an interface, never a hard-coded cast, unless the interceptor is written for one action

getProxy()

The ActionProxy: getActionName(), getNamespace() and getMethod() (the method that will be invoked, execute by default) and getConfig()

getInvocationContext()

The ActionContext: getSession(), getParameters(), getLocale(), getServletRequest(), getServletResponse(), getServletContext(), getValueStack()

getStack()

The ValueStack of this request

addPreResultListener(PreResultListener)

Registers a callback that runs after the action method and before the result; listeners run in registration order

getResultCode(), setResultCode(String)

Reads the result code, or overrides it between the action and the result (from a listener); setting it after the result has executed throws IllegalStateException

isExecuted()

True once the action and the result have both run

getResult()

The Result object that will render the view

Two cautions follow from the Javadoc. First, take the session, request and response from invocation.getInvocationContext() rather than from the thread-local ActionContext.getContext() or the static ServletActionContext accessors: the first is explicit about what it works on, and it keeps the interceptor testable (a test hands in its own ActionContext). Second, the interceptor sees the action before the parameters are bound if it sits above params in the stack, so it must not read action fields that the request is supposed to fill; see the ordering section.

Thread Safety

The Core Developers Guide states the rule plainly: interceptors must be thread-safe. A Struts action instance is created for every request and need not be thread-safe, but interceptors are shared between requests (the framework creates the interceptor objects when it builds the action configuration, not on every request). Every concurrent request that reaches the same action configuration calls intercept on the same object at the same time.

// WRONG: one counter and one start time shared by all concurrent requests
public class BrokenTimerInterceptor extends AbstractInterceptor {
    private long start;                 // overwritten by the next request
    private int calls;                  // lost updates
    @Override
    public String intercept(ActionInvocation invocation) throws Exception {
        start = System.nanoTime();
        calls++;
        try {
            return invocation.invoke();
        } finally {
            System.out.println(System.nanoTime() - start);   // another request's start
        }
    }
}

The fix is the structure of the AuditInterceptor below: per-request values live in local variables inside intercept, configuration set through setters is written once before the first request and only read afterwards, and any shared counter is an AtomicLong or a LongAdder. A map shared across requests is a ConcurrentHashMap. Per-request data that must travel to a later interceptor or the action goes into the ActionContext, the request attributes or the action itself, never into a field of the interceptor.

Declaring Interceptors and Stacks

An interceptor class does nothing until it is declared in struts.xml and referenced from an action or a stack. The declaration has four elements, all children of a package.

Table 5. The configuration elements for interceptors
Element Purpose

<interceptors>

Container inside a <package> for the declarations below

<interceptor name="..." class="..."/>

Gives a class a name; nested <param name="...">value</param> elements set the bean properties of the interceptor through its setters (setHeaderName for headerName)

<interceptor-stack name="...">

An ordered list of <interceptor-ref> elements (and of other stacks); stacks can nest

<interceptor-ref name="..."/>

Uses an interceptor or a stack, in an action or in a stack; nested <param> elements override parameters for this use

<default-interceptor-ref name="..."/>

Names the stack that every action of the package (and of the packages extending it) uses when it has no interceptor-ref of its own

The Bookshelf declares three interceptors, builds one stack that puts them in front of the framework’s defaultStack, and makes that stack the package default. The login global result is what the login-required interceptor returns.

<struts>
    <package name="default" extends="struts-default" namespace="/">
        <interceptors>
            <interceptor name="audit" class="org.example.bookshelf.interceptor.AuditInterceptor"/>
            <interceptor name="trace" class="org.example.bookshelf.interceptor.TraceInterceptor"/>
            <interceptor name="loginRequired" class="org.example.bookshelf.interceptor.LoginRequiredInterceptor"/>
            <interceptor-stack name="bookshelfStack">
                <interceptor-ref name="audit"/>
                <interceptor-ref name="loginRequired">
                    <param name="includeMethods">remove</param>
                </interceptor-ref>
                <interceptor-ref name="defaultStack"/>
            </interceptor-stack>
        </interceptors>
        <default-interceptor-ref name="bookshelfStack"/>

        <global-results>
            <result name="login">/WEB-INF/views/login.jsp</result>
        </global-results>

        <action name="books" class="org.example.bookshelf.BookAction">
            <result>/WEB-INF/views/books.jsp</result>
        </action>
        <action name="book-remove" class="org.example.bookshelf.BookAction" method="remove">
            <result type="redirectAction">books</result>
            <allowed-methods>remove</allowed-methods>
        </action>
    </package>
</struts>

Two details of this file were found by running it. The <allowed-methods> element must come after the <result> elements: the DTD allows only (param|result|interceptor-ref|exception-mapping)* followed by allowed-methods?, and the other order stops the whole application from starting with The content of element type "action" must match (the filter fails to initialise and every URL returns 404). And an action that lists its own <interceptor-ref> elements does not inherit the package default at all: it gets exactly those interceptors, so such an action must list defaultStack itself if it wants the framework’s behaviour, and it silently loses the audit and login interceptors of the default stack. The ordered action later on this page shows that: it has no X-Bookshelf-Audit header.

Bookshelf Example: an Audit Interceptor

The first interceptor records every invocation: namespace, action name, method, the result code, and how long the whole round trip took (including the view). It also reports the outcome to the client in a response header, which needs a PreResultListener because the header has to be set before the JSP starts writing the body. The header name is a parameter, set through setHeaderName from the <param> element of the declaration.

package org.example.bookshelf.interceptor;

import jakarta.servlet.http.HttpServletResponse;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.ActionProxy;
import org.apache.struts2.interceptor.AbstractInterceptor;
import org.apache.struts2.interceptor.PreResultListener;

/** Logs every invocation of an action and reports the outcome in a response header. */
public class AuditInterceptor extends AbstractInterceptor {
    private static final Logger LOG = LogManager.getLogger(AuditInterceptor.class);
    private String headerName = "X-Bookshelf-Audit";

    // Set from <param name="headerName"> in struts.xml, once, before requests arrive.
    public void setHeaderName(String headerName) {
        this.headerName = headerName;
    }

    @Override
    public String intercept(ActionInvocation invocation) throws Exception {
        // All per-request state lives in local variables: this instance is shared by every request.
        ActionProxy proxy = invocation.getProxy();
        String target = proxy.getNamespace() + proxy.getActionName() + "!" + proxy.getMethod();
        long start = System.nanoTime();

        invocation.addPreResultListener(new PreResultListener() {
            @Override
            public void beforeResult(ActionInvocation inv, String resultCode) {
                HttpServletResponse response = inv.getInvocationContext().getServletResponse();
                if (response != null && !response.isCommitted()) {
                    response.setHeader(headerName, target + " -> " + resultCode);
                }
            }
        });

        String result = null;
        try {
            result = invocation.invoke();
            return result;
        } finally {
            long micros = (System.nanoTime() - start) / 1_000;
            LOG.info("audit {} result={} took={}us", target, result, micros);
        }
    }
}

Calling the action shows the header and, with a log4j2.xml that enables INFO for the org.example.bookshelf logger, the log line (the default Log4j2 configuration that Struts ships logs only warnings and errors, so without that file the LOG.info line is invisible and only the header proves the interceptor ran).

$ curl -s -i http://localhost:8080/bookshelf/books | grep -iE '^HTTP|X-Bookshelf|<li>'
HTTP/1.1 200
X-Bookshelf-Audit: /books!execute -> success
<li>Dune</li><li>Neuromancer</li>
15:51:03 INFO  AuditInterceptor - audit /books!execute result=success took=2794us

The finally block is deliberate: if the action or the view throws, the audit line is still written (with a null result), and the exception continues to the exception interceptor. Logging request parameters in an audit interceptor needs care: parameter values are attacker-controlled text, so log them encoded or truncated (log injection through newlines) and never log passwords, tokens or session ids.

PreResultListener

PreResultListener is the interceptor-side hook for "after the action, before the view": a single method, beforeResult(ActionInvocation invocation, String resultCode). The listener is registered by an interceptor with invocation.addPreResultListener(...) while the request is going down the stack; the framework calls the listeners when the action method has returned, in the order they were registered, and then executes the result. The result code passed in is the one the action returned, and invocation.setResultCode(...) can replace it (the Javadoc says that this is the intended use of that setter, and that calling it after the result has executed throws IllegalStateException).

sequenceDiagram participant F as Filter participant A as Interceptor A participant B as Interceptor B participant X as Action participant R as Result (JSP) F->>A: intercept(invocation) Note over A: code before invoke(), registers listener A A->>B: invocation.invoke() Note over B: code before invoke(), registers listener B B->>X: invocation.invoke() reaches the action X-->>B: result code, for example success Note over B,R: listeners run in registration order: A, then B B->>R: result executes, JSP rendered R-->>B: invoke() returns Note over B: code after invoke() B-->>A: returns the result code Note over A: code after invoke() A-->>F: returns the result code
Figure 1. The order of events for a request through two interceptors, A above B, each registering a listener

The diagram is what the TraceInterceptor of the Bookshelf prints. Each of the two interceptors logs before and after invoke() and registers a listener that appends its label to a response header:

public class TraceInterceptor extends AbstractInterceptor {
    private static final Logger LOG = LogManager.getLogger(TraceInterceptor.class);
    private String label = "?";

    public void setLabel(String label) {
        this.label = label;
    }

    @Override
    public String intercept(ActionInvocation invocation) throws Exception {
        LOG.info("before {}", label);
        invocation.addPreResultListener((inv, resultCode) -> {
            HttpServletResponse response = inv.getInvocationContext().getServletResponse();
            String previous = response.getHeader("X-Trace");
            response.setHeader("X-Trace", previous == null ? label : previous + "," + label);
        });
        try {
            return invocation.invoke();
        } finally {
            LOG.info("after {}", label);
        }
    }
}
<action name="ordered" class="org.example.bookshelf.BookAction">
    <interceptor-ref name="trace"><param name="label">A</param></interceptor-ref>
    <interceptor-ref name="trace"><param name="label">B</param></interceptor-ref>
    <interceptor-ref name="defaultStack"/>
    <result>/WEB-INF/views/books.jsp</result>
</action>
$ curl -s -i http://localhost:8080/bookshelf/ordered | grep -iE '^HTTP|X-Trace'
HTTP/1.1 200
X-Trace: A,B
15:50:56 INFO  TraceInterceptor - before A
15:50:56 INFO  TraceInterceptor - before B
15:50:56 INFO  TraceInterceptor - after B
15:50:56 INFO  TraceInterceptor - after A

The two log blocks are the whole rule of ordering: "before" code runs top to bottom, "after" code bottom to top, and the pre-result listeners fire top to bottom in between. One interceptor class declared once can be referenced twice in a stack with different parameters, as trace is here.

MethodFilterInterceptor

An interceptor often matters for some methods of an action and not others: a login check on remove but not on execute, validation on save but not on input. MethodFilterInterceptor is the abstract base class for exactly that. The subclass implements doIntercept(ActionInvocation) instead of intercept, and the base class applies two bean properties to decide whether to call it at all: includeMethods and excludeMethods, comma-separated method names. If a method name is in both lists it counts as included: includeMethods takes precedence over excludeMethods. The built-in interceptors built on it include token, tokenSession, workflow and validation; the Javadoc lists params, prepare, conversionError and execAndWait among its subclasses as well.

Table 6. The members of MethodFilterInterceptor that a subclass uses
Member Purpose

protected abstract String doIntercept(ActionInvocation)

The interceptor logic; called only when the method passes the filter

setIncludeMethods(String) / setExcludeMethods(String)

The includeMethods and excludeMethods parameters; fed from <param> elements or from the stack.interceptor.param override syntax

getIncludeMethodsSet() / getExcludeMethodsSet()

The parsed sets

protected boolean applyInterceptor(ActionInvocation)

The filter decision; override to change how methods are matched

protected Set<String> includeMethods, excludeMethods

The fields behind the properties

The method that the filter compares against is the one the action is mapped to (ActionProxy.getMethod()), so the filter works with the method attribute of an action mapping (the example below maps book-remove to remove), the same way the validation interceptor’s excludeMethods of the default stack does. The default stack itself ships with excludeMethods set to input,back,cancel,browse on validation and workflow in 7.4.0.

Bookshelf Example: a Login-Required Interceptor

Removing a book is a state-changing operation: it must be reachable only by a signed-in user, while listing books is public. The interceptor extends MethodFilterInterceptor, reads the user from the session through the invocation context, and returns the login result without running the action when there is none.

package org.example.bookshelf.interceptor;

import java.util.Map;
import org.apache.struts2.ActionInvocation;
import org.apache.struts2.interceptor.MethodFilterInterceptor;

/** Sends anonymous users to the "login" result before the action method runs. */
public class LoginRequiredInterceptor extends MethodFilterInterceptor {
    public static final String USER_KEY = "bookshelf.user";

    @Override
    protected String doIntercept(ActionInvocation invocation) throws Exception {
        Map<String, Object> session = invocation.getInvocationContext().getSession();
        if (session == null || session.get(USER_KEY) == null) {
            return "login"; // the action is never executed
        }
        return invocation.invoke();
    }
}

The BookAction has a public execute and a guarded remove; the declaration above applies the interceptor with includeMethods set to remove. The LoginAction is a demonstration sign-in: it stores a user name in the session and, as it should after authentication, starts a new session id. A real application verifies a credential here; no password or key appears in the example.

public class LoginAction extends ActionSupport implements SessionAware, ServletRequestAware {
    private Map<String, Object> session;
    private HttpServletRequest request;
    private String user;

    @StrutsParameter
    public void setUser(String user) {
        this.user = user;
    }

    @Override
    public void withSession(Map<String, Object> session) {
        this.session = session;
    }

    @Override
    public void withServletRequest(HttpServletRequest request) {
        this.request = request;
    }

    @Override
    public String execute() {
        if (user == null || user.isBlank()) {
            return INPUT;
        }
        request.getSession(true);  // changeSessionId() needs an existing session
        request.changeSessionId(); // new id after authentication: no session fixation
        session.put(LoginRequiredInterceptor.USER_KEY, user);
        return SUCCESS;
    }
}

Calling it from curl shows both paths. An anonymous POST to book-remove gets the login page and the audit header shows the short-circuit (remove returned login, and the audit interceptor above it still reported the outcome); after a sign-in with a cookie jar the same POST reaches the action and the result is a redirect to the list.

$ curl -s -i -X POST http://localhost:8080/bookshelf/book-remove | grep -iE '^HTTP|X-Bookshelf|<h1>'
HTTP/1.1 200
X-Bookshelf-Audit: /book-remove!remove -> login
<h1>Sign in</h1>

$ curl -s -i -c jar -b jar -X POST -d user=ana http://localhost:8080/bookshelf/login | grep -iE '^HTTP|X-Bookshelf|Set-Cookie'
HTTP/1.1 302
Set-Cookie: JSESSIONID=...; Path=/bookshelf; HttpOnly
X-Bookshelf-Audit: /login!execute -> success

$ curl -s -i -c jar -b jar -X POST http://localhost:8080/bookshelf/book-remove | grep -iE '^HTTP|X-Bookshelf|Location'
HTTP/1.1 302
X-Bookshelf-Audit: /book-remove!remove -> success
Location: /bookshelf/books.action

$ curl -s -i http://localhost:8080/bookshelf/books | grep -iE '^HTTP|X-Bookshelf'
HTTP/1.1 200
X-Bookshelf-Audit: /books!execute -> success

Three security points about this interceptor. It authenticates; it does not authorise, so a signed-in user can remove any book (per-object checks belong in the action or a service; the built-in roles interceptor is the ready-made check against container roles). It must be placed above params so that an anonymous request is rejected before any request parameter is bound to the action (see ordering). And remove is a state-changing method reached only by POST in the example: pair it with the token interceptor, or the httpMethod interceptor of the default stack, for CSRF and verb restrictions rather than trusting a "login" check alone. A redirect target taken from a request parameter ("return to the page the user came from") must be validated against a fixed list, or it becomes an open redirect.

Ordering: Where the Interceptor Goes in the Stack

An interceptor’s position decides what it can rely on and what is protected by it. The defaultStack of Struts 7.4.0 (the declaration is in struts-default.xml of the 7.4.0 jar) is, from the first to run to the last:

Table 7. The defaultStack of Struts 7.4.0 in execution order
Position Interceptors

1-4

exception, alias, servletConfig, httpMethod

5-8

i18n, csp, prepare, chain

9-14

scopedModelDriven, modelDriven, actionFileUpload, checkbox, datetime, multiselect

15-18

staticParams, actionMappingParams, params, conversionError

19-24

coep, coop, fetchMetadata, validation, workflow, debugging

The csp, coep, coop and fetchMetadata entries are the 7.x security-header and Fetch Metadata interceptors, all declared with disabled set to false in the stack (csp, coep and coop in non-enforcing mode where the stack says so). A custom interceptor goes before, inside or after this stack, and the rules are these.

Table 8. Where to place a custom interceptor
Placement Effect Typical use

Before defaultStack (first in a custom stack)

Runs before parameters are bound, before validation, outside the exception interceptor’s protection: an exception thrown here is not mapped by exception-mapping

Audit and timing that must cover everything; authentication and authorisation checks that must reject a request before any request data touches the action

After defaultStack (last)

Runs when parameters are bound and validation has run (a validation failure has already short-circuited with input through workflow); the action has the data

Checks that depend on the bound data, such as ownership of the book id in the request

Between two specific interceptors

Use two interceptor-ref lists (copy the stack, as the guide’s Method 1 does) or nest your own stack

A step that needs the result of prepare or modelDriven but not the bound parameters

First of everything, even before exception

Not possible with defaultStack as one unit

A custom stack that lists exception first and then your interceptor reproduces the default behaviour for exceptions

The guide warns that "some Interceptors will interrupt the stack/chain/flow …​ so the order is very important", and that the order matters most when an interceptor reads parameters that the params interceptor sets: such an interceptor must be placed after it. The inverse is the security argument of this page: an interceptor that decides whether the request may proceed belongs above params, so that a rejected request never has its parameters injected into the action. The Bookshelf stack puts audit first (it measures everything, including the other interceptors), loginRequired second (rejects before parameters are bound) and defaultStack last.

Interceptor Parameters and Overriding

Properties of an interceptor are set from <param> elements, and the same properties can be overridden where the interceptor is used. The Core Developers Guide describes three ways, and the third matters when a custom stack wraps the default one.

Table 9. The ways to override an interceptor parameter
Method Syntax Notes

1: copy the stack

List every interceptor-ref of the stack in the action and put the <param> on the one to change

Verbose; you own the list and must keep it in step with defaultStack when Struts adds an interceptor

2: interceptor.parameter on the stack reference

<interceptor-ref name="defaultStack"><param name="validation.excludeMethods">save</param></interceptor-ref>

The word before the dot is the interceptor name inside the stack, the word after it is the parameter

3: the same override inside a custom stack

<interceptor-stack name="parentStack"><interceptor-ref name="defaultStack"><param name="params.excludeParams">token</param></interceptor-ref></interceptor-stack>

Overrides are not inherited between stack levels: an action that overrides one parameter of an interceptor gets only that parameter, the other parameters set by the parent stack for that interceptor are null

Disabling an Interceptor for One Action

Since Struts 6.1.0, every interceptor that extends AbstractInterceptor (all the built-in ones do) accepts a disabled parameter: when it is true, the interceptor is skipped for that invocation (shouldIntercept returns false and the framework calls invoke() for it). The syntax is the stack-override form interceptor.disabled. The Bookshelf quiet action runs the same stack as the others but without the audit interceptor, so the header is absent:

<action name="quiet" class="org.example.bookshelf.BookAction">
    <interceptor-ref name="bookshelfStack">
        <param name="audit.disabled">true</param>
    </interceptor-ref>
    <result>/WEB-INF/views/books.jsp</result>
</action>
$ curl -s -i http://localhost:8080/bookshelf/quiet | grep -iE '^HTTP|X-Bookshelf|<li>'
HTTP/1.1 200
<li>Dune</li><li>Neuromancer</li>

Disabling is a configuration switch, not a security boundary: it is meant for diagnostics and for the few actions that legitimately must not run an interceptor. Never disable a security interceptor (a login check, params filtering, validation) to make an error disappear. Because disabled is a parameter like any other, an interceptor extending Interceptor directly instead of AbstractInterceptor has no disabled property.

Lazy Parameters

Normally an interceptor’s parameters are fixed when the interceptor is built, so a <param> is a constant. Since Struts 2.5.9 a parameter can be evaluated against the action while the request is running, with an expression such as ${bar} in the <param> value, if the interceptor implements WithLazyParams. The interceptor then cannot hold the parameters in its own fields (they differ per request); it declares a small InterceptorParams type, and the framework writes the evaluated values into a fresh holder of that type for each invocation (the Javadoc: never back onto the shared interceptor) and passes it to the two-argument intercept method. The Core Developers Guide still prints the pre-7 shape of this example (a non-generic WithLazyParams and a one-argument intercept with setters on the interceptor); the 7.4.0 interface is generic:

public interface WithLazyParams<P extends InterceptorParams> {
    P newLazyParams();
    String intercept(ActionInvocation invocation, P params) throws Exception;
}

The page does not run a lazy-parameter example; use the interface above and the guide’s configuration form as the reference, and remember the guide’s reminder that the order matters when a lazy parameter reads a request parameter: the params interceptor must already have run. Evaluating an expression from configuration is safe only because the expression is written by the developer in struts.xml; never build such a <param> value from request data.

Interceptors with Spring, Annotations and the Convention Plugin

The interceptor declarations above are plain Struts configuration. Interceptors that need services (a repository, a clock, an audit sink) get them through the object factory: with the Spring plugin installed, the class attribute can be a Spring bean id and the interceptor is wired by Spring; without it, Struts instantiates the class with its no-argument constructor and the services come from static holders or a lookup. Actions built with the Convention plugin attach interceptors with @InterceptorRef and @InterceptorRefs on the action class rather than with XML. Both belong to other pages of this section (Spring Integration and Convention Plugin & Annotations): they are named here so the extension points are not a surprise, and are not demonstrated on this page.

Testing an Interceptor

An interceptor depends only on the ActionInvocation, so it can be tested without a container. Struts 7.4.0 ships test doubles in struts2-core itself: org.apache.struts2.mock.MockActionInvocation and MockActionProxy. The mock invocation’s invoke() returns the result code that the test set with setResultCode, so a test can tell whether the interceptor called through (the code the "action" returned) or short-circuited (its own code). The three tests below cover the anonymous, signed-in and non-included-method cases of the Bookshelf login interceptor; they ran under JUnit 6.1.3 (org.junit.jupiter:junit-jupiter 6.1.3, test scope) with mvn package, three tests, no failures.

class LoginRequiredInterceptorTest {

    private MockActionInvocation invocation(Map<String, Object> session, String method) {
        MockActionProxy proxy = new MockActionProxy();
        proxy.setMethod(method);
        MockActionInvocation invocation = new MockActionInvocation();
        invocation.setProxy(proxy);
        invocation.setInvocationContext(ActionContext.of(new HashMap<>()).withSession(session));
        invocation.setResultCode("success"); // what invoke() returns when the action is reached
        return invocation;
    }

    private LoginRequiredInterceptor interceptor() {
        LoginRequiredInterceptor interceptor = new LoginRequiredInterceptor();
        interceptor.setIncludeMethods("remove");
        return interceptor;
    }

    @Test
    void anonymousUserIsSentToLogin() throws Exception {
        String result = interceptor().intercept(invocation(new HashMap<>(), "remove"));
        assertEquals("login", result);
    }

    @Test
    void signedInUserReachesTheAction() throws Exception {
        Map<String, Object> session = new HashMap<>();
        session.put(LoginRequiredInterceptor.USER_KEY, "ana");
        assertEquals("success", interceptor().intercept(invocation(session, "remove")));
    }

    @Test
    void methodsOutsideIncludeMethodsAreNotChecked() throws Exception {
        assertEquals("success", interceptor().intercept(invocation(new HashMap<>(), "execute")));
    }
}

The test calls intercept on the interceptor, which is the public entry point of MethodFilterInterceptor; the filter decides whether to call doIntercept, so the third test exercises the filter and the first two the logic. Tests that must run the whole stack with the real configuration (that the order is right, that the global login result resolves) belong to the JUnit plugin’s StrutsTestCase style of integration test, or to a curl check like the ones on this page against a running container: the unit test cannot see a misspelt interceptor-ref.

Pitfalls

Table 10. The mistakes that custom interceptors most often cause
Symptom Cause Fix

Wrong values appear in logs or in responses under load

State kept in an instance field of the interceptor; the instance is shared between concurrent requests

Use local variables inside intercept; make shared state atomic or concurrent

The action runs twice, or a short-circuited request still reaches the action

invoke() called twice, or called after the interceptor decided to reject

Return the rejection result immediately and call invoke() at most once

The application does not start; every URL returns 404 and the log shows The content of element type "action" must match

<allowed-methods> placed before the <result> elements

Put <allowed-methods> last in the action

A header set after invoke() is missing

The result has already been rendered and the response committed

Set it before the result, in a PreResultListener, or before invoke()

IllegalStateException from setResultCode

Called after the result has executed

Call it from a PreResultListener, before the result

The audit and login interceptors are not running for one action

The action lists its own interceptor-ref elements, which replace the package’s default-interceptor-ref

List the full stack in the action, or put the custom stack in the reference (bookshelfStack)

An unauthenticated request still changes an action field

The login interceptor sits after params

Put authentication before defaultStack

The interceptor throws ClassCastException for some actions

getAction() cast to one concrete class, applied to a stack used by many actions

Test with instanceof against an interface

The interceptor’s init() never runs or runs late

Initialisation was put in the constructor, or the interceptor was expected to start at deployment

Do it in init(); it runs after the parameters are set

A unit test fails with a null pointer on the session or response

The interceptor read the thread-local ActionContext.getContext(), which a plain test never sets

Read everything from invocation.getInvocationContext() and pass an ActionContext in the test

changeSessionId throws IllegalStateException: no session

HttpServletRequest.changeSessionId() called when the request has no session yet

Call getSession(true) first (the LoginAction above does)

An interceptor extending only Interceptor ignores disabled

disabled is implemented by AbstractInterceptor

Extend AbstractInterceptor (or MethodFilterInterceptor)

What Changed Recently

Table 11. Changes that affect interceptor code, with the version where each appeared
Version Change

2.5.9

Lazy parameters through WithLazyParams

6.1.0

The disabled parameter for interceptors that extend AbstractInterceptor

7.0.0

The com.opensymphony.xwork2 package is renamed to org.apache.struts2 (the migration guide: a search and replace is all that is necessary), so Interceptor, AbstractInterceptor, MethodFilterInterceptor, PreResultListener and ActionInvocation have the org.apache.struts2 names; namespace matching is exact by default

7.4.0 (state verified in the jar)

defaultStack contains httpMethod, csp, coep, coop and fetchMetadata as well as the classic interceptors, and actionFileUpload instead of the old fileUpload; AbstractInterceptor implements ConditionalInterceptor; WithLazyParams is generic. The versions that introduced each of these were not checked

Where the Books Are Outdated

The two Struts books consulted for concept coverage predate these names and defaults, and are not reproduced here.

Table 12. Interceptor material in the books that no longer matches Struts 7.4
Book material Struts 7.4

Struts 2 in Action (Struts 2.0): the com.opensymphony.xwork2 imports for Interceptor, AbstractInterceptor, ActionInvocation

org.apache.struts2.interceptor.* and org.apache.struts2.ActionInvocation; the Core Developers Guide still shows the old import

The fileUpload interceptor in the default stack (Struts 2 in Action chapter 3)

actionFileUpload in 7.4.0, as the table above shows; the 6.0 DTD is still the newest DTD, so the examples on this page use it

The interceptor named both scoped-model-driven and scopedModelDriven

The name in struts-default.xml is scopedModelDriven

A custom authentication interceptor (Struts 2 in Action chapter 4)

The session map comes from invocation.getInvocationContext().getSession(), actions receive it through SessionAware.withSession, the login result should start a new session id, and any setter the params interceptor may call needs @StrutsParameter

Struts in Action (Struts 1) has no interceptors: its extension points were the RequestProcessor and the declarative exception handler, which Struts 2 replaced with the interceptor chain; the Struts 1 (Classic) & Migration page covers the correspondence. The official documentation is itself stale in places: the writing-interceptors page uses the removed com.opensymphony.xwork2 imports and the interceptors page prints a lazy-parameter example against the old non-generic interface.

References