Double-Submit Tokens & Wait Pages

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) running on Apache Tomcat 11.0.26 and Java 21, the combination on which every example was built, deployed and requested; the facts were checked on 2026-10-08 against the struts-default.xml file and the sources of the interceptor classes in the 7.4.0 struts2-core jar, the Core Developer Guide pages for the three interceptors and the Tag Developers Guide page of the token tag. Two problems of real applications are solved by the interceptors of this page. A user who clicks a submit button twice, reloads a page that was the answer to a POST, or presses the back button and submits again makes the server do the same work twice: two books on the shelf, two orders, two e-mails. The token and tokenSession interceptors, together with the s:token tag, stop that by letting a form be processed once. The second problem is the request that takes so long that the browser, a proxy or the user gives up: the execAndWait interceptor runs the action in a background thread and answers at once with a wait page that reloads itself until the job is done. The running scenario is the Bookshelf, a catalogue of books exposed as Struts actions; here it adds books through a protected form and imports a batch of books as a slow job. The neighbouring pages are the value stack, parameter injection, validation, the form tags and security hardening.

The Problem: One Click, Two Requests

A browser can send the same request more than once without the user meaning it. Each case needs a different part of the cure, so the list below is also the plan of this page.

Table 1. How a form is submitted twice and what stops it
Cause What happens on the server Defence

The user double-clicks the submit button

Two identical POST requests arrive within milliseconds, usually on two threads of one session

token (the second request is rejected) or tokenSession (the second request gets the answer of the first); a button that disables itself in JavaScript is only a convenience

The user reloads the page that answered a POST

The browser asks "resend the form data?" and repeats the request with the same body

A token makes the repeat invalid; redirecting after the POST (the Post/Redirect/Get pattern) avoids the question altogether

The user presses the back button and submits the old form again

The old form still holds the old token or none at all

A token that is removed from the session when it is used is invalid the second time

The response is slow and the user clicks again

Several copies of a slow job run in parallel

execAndWait keeps one background job per session and action name and answers every further request with the wait result

Two tabs of the same session show the same form

Both carry the same token name; the second rendering replaces the first token in the session

Give every form its own token name (see Named Tokens, Several Forms and Several Tabs)

A page on another site makes the browser send a POST to the application

The request carries the session cookie of the victim; this is cross-site request forgery, not a double submit

A token the attacker cannot guess helps, but so do SameSite cookies and Fetch Metadata; see Tokens, CSRF, SameSite and Fetch Metadata

The first four rows are the synchronizer token pattern, which Struts 1 offered as saveToken and isTokenValid in the Action base class and which the Struts 2 books call the double-submit guard: the server stores a random value in the session when it renders the form, the form carries the same value, and the server accepts the form once, removing the value as it does. Everything else in this page is a variation of that sentence.

The Bookshelf Used on This Page

All examples are classes and mappings of one WAR, g9token.war, deployed under the context path /g9token on Tomcat 11.0.26. It is the Struts Bookshelf of the Struts Getting Started page of this section (Struts 7.4.0 through the struts2-bom, jakarta.servlet-api 6.1.0 as provided, the filter StrutsPrepareAndExecuteFilter mapped to /*, JSPs under WEB-INF/views) reduced to one shared in-memory shelf, Shelf.TITLES, that starts with Dune and Neuromancer, plus a class that makes the slow job. Every action below writes to the same shelf, so the shelf count is the evidence of how often an action really ran.

Table 2. The actions of the scratch WAR
URL What it demonstrates Interceptors

add-form.action

A form with a title field and s:token that posts to add-book

defaultStack

add-plain.action

Adds a book with no protection at all (the control)

defaultStack

add-book.action

Adds a book once per token

tokenStack (defaultStack, then token)

add-book-early.action

The same with token before defaultStack, to show an ordering effect

tokenFirstStack

add-book-session.action

Adds a book once and shows the first answer again on a repeat

tokenSessionStack

shelf-list.action, shelf-save.action

One wildcard mapping, the token checked for save only

defaultStack, token with excludeMethods

import-books.action

A slow import of four books, with a wait page

executeAndWaitStack

import-validated.action

The same import with a stack that works (see The Stack Layout and the Built-in executeAndWaitStack)

validatedWaitStack

search.action

The same job with execAndWait last and an initial delay

searchStack

import-token.action

The slow job behind a token

tokenWaitStack

AddBookAction extends ActionSupport, has the setters setTitle and setPauseMs (both annotated @StrutsParameter, which Struts 7 requires for every request parameter that reaches an action), and its execute() sleeps for pauseMs milliseconds before appending the title to the shelf; the sleep makes a race between two simultaneous requests easy to provoke. The results are two JSPs: added.jsp prints the shelf size and titles, and invalid.jsp prints the action errors and the shelf size. The message bundle global.properties (and the Spanish global_es.properties) is registered with struts.custom.i18n.resources and holds the text of the invalid-token message.

The s:token Tag and the TokenHelper

A form is protected by placing s:token inside it. The tag writes two hidden fields: struts.token.name, which holds the name of the token, and a field with that name which holds the token itself. The name defaults to token, so the form of the Bookshelf is:

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html>
<html><body>
<h1>Add a book</h1>
<s:form action="add-book" method="post" theme="simple">
  <s:textfield name="title"/>
  <s:token/>
  <s:submit value="Add"/>
</s:form>
</body></html>

The action of the form is a fixed action name. Never build it from request input (for example %{#parameters.target[0]}): s:form writes a value that is not a mapped action into the HTML as it is, so a link with target=https://evil.example/ would make the browser post the fresh token, and the rest of the form, to another host. Requesting add-form.action produced this HTML on Tomcat 11.0.26 (the token differs on every request):

<form id="add_book" name="add_book" action="/g9token/add-book.action;jsessionid=4B33313CAFAF9916A9DAD008BA155DC1" method="post">
  <input type="text" name="title" value="" id="add_book_title"/>
  <input type="hidden" name="struts.token.name" value="token" />
<input type="hidden" name="token" value="LQTVVK291CTHWY4UYZ2D19W00BI9O9M4" />
  <input type="submit" value="Add" id="add_book_0"/>
</form>

The class behind the tag and the interceptors is org.apache.struts2.util.TokenHelper. Reading its source in the 7.4.0 jar gives the facts that the guides leave out:

  • The token is a 165-bit number from a SecureRandom, written in base 36 in capitals (about 32 characters, as above), so it cannot be guessed or enumerated.

  • It is stored in the session under the attribute struts.tokens. followed by the token name, for the default name struts.tokens.token. There is one token per name and session, not one per rendered form: rendering the tag a second time with the same name replaces the first token (see Named Tokens, Several Forms and Several Tabs).

  • The interceptor reads the name from the request parameter struts.token.name (the constant TokenHelper.TOKEN_NAME_FIELD) and the token from the parameter that has that name, compares it with the session value, and on a match removes the session value, so the token is good for one request.

  • The tag needs a session. If the response is already committed when the tag tries to create the session, TokenHelper logs "Error creating HttpSession due response is committed" and throws; the createSession interceptor, placed before the page is rendered, or request.getSession() in the action avoids it. Servlet sessions are explained on the servlet fundamentals page.

  • In development mode (struts.devMode) a mismatch logs the token name, the form token and the session token at WARN level; in production only the form token is logged.

How a Token Travels

sequenceDiagram participant B as Browser participant F as add-form.action participant T as token interceptor participant A as AddBookAction participant S as HttpSession B->>F: GET add-form.action F->>S: store struts.tokens.token = K9T... F-->>B: form with struts.token.name and token K9T... B->>T: POST title=Emma, token=K9T... T->>S: equals the stored token? remove it S-->>T: yes T->>A: invoke execute() A-->>B: shelf with 3 books B->>T: POST again with the same token T->>S: equals the stored token? S-->>T: no, already removed T-->>B: result invalid.token, action not run

The diagram is the whole mechanism. Two consequences matter in practice. The token is checked at the moment the interceptor runs, so everything the stack runs before it (parameter binding, type conversion, validation) has already happened, and everything after it only runs for a valid token. And because the token lives in the session, a request that arrives without the session cookie, with a token from another session or with no token at all is invalid for the same reason as a replay; the interceptor cannot tell them apart.

The token Interceptor

The interceptor named token is the class org.apache.struts2.interceptor.TokenInterceptor. Its Javadoc and the Core Developer Guide say that it ensures that only one request per token is processed, so that back buttons and double clicks do not cause unintended side effects. It has no parameters of its own, but it extends MethodFilterInterceptor, so includeMethods and excludeMethods work (see Protecting Some Methods Only). It is registered in struts-default.xml but is not part of defaultStack or any other built-in stack: an action opts in by naming it.

Configuring It: The Stack Order

An interceptor-ref on an action replaces the package default stack, so the action must list defaultStack too. The order matters, and the Core Developer Guide examples place token before basicStack. In the Bookshelf the token check runs after defaultStack, in a stack of its own:

<package name="default" extends="struts-default" namespace="/">
    <interceptors>
        <!-- the token check after the default stack, so that i18n has already chosen the locale -->
        <interceptor-stack name="tokenStack">
            <interceptor-ref name="defaultStack"/>
            <interceptor-ref name="token"/>
        </interceptor-stack>
    </interceptors>

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

    <action name="add-book" class="org.example.bookshelf.AddBookAction">
        <interceptor-ref name="tokenStack"/>
        <result>/WEB-INF/views/added.jsp</result>
    </action>
</package>

The mapping of invalid.token is the part that people forget; it is explained in the next section. A browser-like session with curl shows the whole behaviour. The first request gets the form and its token, the second posts it, the third posts it again, and the fourth posts without any token (the transcript shows the visible text of each answer):

$ tok = token read from add-form.action, same cookie jar for every request
POST add-book.action title=Emma struts.token.name=token token=<tok>
 Shelf Count: 3 Dune Neuromancer Emma
POST add-book.action title=Emma struts.token.name=token token=<tok>      (replay)
 Not added This form was already sent. Check the shelf before adding the book again. Count: 3
POST add-book.action title=Emma                                           (no token)
 Not added This form was already sent. Check the shelf before adding the book again. Count: 3
POST add-plain.action title=Emma   (twice, no protection)
 Shelf Count: 4 Dune Neuromancer Emma Emma
 Shelf Count: 5 Dune Neuromancer Emma Emma Emma

The replay and the missing token both ended on invalid.jsp and did not touch the shelf (count 3), while the control action add-plain added the book every time. Requests with the token in the URL of a GET behave the same way: the interceptor reads request parameters, whatever their origin (add-book.action?title=Q&struts.token.name=token&token=<tok> added the book once and was invalid the second time).

The invalid.token Result and Its Message

When the token is invalid the interceptor does not call the rest of the stack. It returns the result code invalid.token (the constant TokenInterceptor.INVALID_TOKEN_CODE), and before it does so it adds an action error to the action, if the action implements ValidationAware (ActionSupport does), or only logs a warning otherwise. The Core Developer Guide calls this "a fairly primitive technique" and points to tokenSession for better handling. The result invalid.token is an ordinary result name that you map; with no mapping the request ends in an error response because no result of that name exists, so map it in the package’s global-results (as above) or in each action.

The message comes from the key struts.messages.invalid.token. The text provider of the action is asked for it and, if no resource bundle defines it, the built-in default is used: "The form has already been processed or no token was supplied, please try again." The Bookshelf overrides it in global.properties and, for the Spanish locale, in global_es.properties:

struts.messages.invalid.token=This form was already sent. Check the shelf before adding the book again.
struts.messages.invalid.token=Este formulario ya se envio. Compruebe la estanteria antes de anadir el libro otra vez.

The locale used for the message is the locale of the invocation at the moment the token interceptor runs. That is the reason for the stack order of the Bookshelf. With defaultStack first, the i18n interceptor has already read request_locale or Accept-Language, and a Spanish browser got the Spanish message; with token before defaultStack (the mapping add-book-early) a request with request_locale=es in a fresh session got the English text, because i18n had not run yet:

POST add-book-early.action title=X request_locale=es    (token interceptor first)
 Not added This form was already sent. Check the shelf before adding the book again. Count: 2
POST add-book.action       title=X request_locale=es    (defaultStack first)
 Not added Este formulario ya se envio. Compruebe la estanteria antes de anadir el libro otra vez. Count: 2

The trade-off is that with defaultStack first, parameter binding, type conversion and validation have already run for a request whose token is going to be rejected. That is harmless when those steps only fill fields, but an expensive prepare() or a validator that queries a database is paid for on every duplicate. The Interceptors page of this section (Interceptors) describes the order and the nesting of the stack, and the i18n interceptor and the request_locale parameter belong to the localization page of the section.

Protecting Some Methods Only

The token interceptor is a MethodFilterInterceptor, so a mapping that uses one action class for several methods can protect just the ones that change data. The Core Developer Guide shows excludeMethods for a method called myMethod; in the Bookshelf the wildcard mapping shelf-* (the wildcard and method invocation page of this section, Wildcard Mappings and Method Invocation) maps shelf-list to list() and shelf-save to save(), and only save needs the token:

<action name="shelf-*" class="org.example.bookshelf.AddBookAction" method="{1}">
    <interceptor-ref name="defaultStack"/>
    <interceptor-ref name="token">
        <param name="excludeMethods">list</param>
    </interceptor-ref>
    <result>/WEB-INF/views/added.jsp</result>
    <allowed-methods>list,save</allowed-methods>
</action>

The requests below were sent in one session. The listing needs no token, the save without a token is refused, and the save with a token read from add-form.action adds the book (the token belongs to the session and its name, not to the form that rendered it, see Named Tokens, Several Forms and Several Tabs):

GET  shelf-list.action                                    Shelf Count: 2 Dune Neuromancer
POST shelf-save.action title=Kim                          Not added This form was already sent. ... Count: 2
POST shelf-save.action title=Kim struts.token.name=token token=<tok>
                                                          Shelf Count: 3 Dune Neuromancer Kim

The method names in excludeMethods are the Java method names (list), not the action names. The element allowed-methods lists the methods that a wildcard may call (see the wildcard page).

The tokenSession Interceptor

tokenSession is the name that struts-default.xml gives to the class TokenSessionStoreInterceptor (the class that the Token Interceptor page mentions in braces as {@link TokenSessionStoreInterceptor}). It extends TokenInterceptor and changes only what happens to an invalid token. The Core Developer Guide says that it blocks later requests until the first one is complete and then, instead of the result invalid.token, "attempts to display the same response that the original, valid action invocation would have displayed". The code in 7.4.0 shows how:

  • The check runs inside a block synchronized on the interned session id, so two simultaneous requests of one session are serialized: the second waits for the first to finish.

  • A valid token makes the interceptor store the whole completed ActionInvocation (the action, its value stack and its result) in the session, in the InvocationSessionStore, keyed by the token, and then run the action.

  • An invalid token whose value is found in that store makes the interceptor execute the stored result again against the new response and suppress the result of the current invocation. If the store has nothing for the token (a token that never existed, or a request with no token) the result is invalid.token as before.

Configured with the same layout as tokenStack, the same replay now shows the answer of the first request again, and a request without token still fails:

<interceptor-stack name="tokenSessionStack">
    <interceptor-ref name="defaultStack"/>
    <interceptor-ref name="tokenSession"/>
</interceptor-stack>

<action name="add-book-session" class="org.example.bookshelf.AddBookAction">
    <interceptor-ref name="tokenSessionStack"/>
    <result>/WEB-INF/views/added.jsp</result>
</action>
POST add-book-session.action title=Emma struts.token.name=token token=<tok>
 Shelf Count: 3 Dune Neuromancer Emma
POST add-book-session.action title=Emma struts.token.name=token token=<tok>   (replay)
 Shelf Count: 3 Dune Neuromancer Emma                                          (the first answer again, nothing added)
POST add-book-session.action title=Emma                                        (no token)
 Not added Count: 3

The user who double-clicked gets a normal page instead of an error, which is the point of the interceptor. The test that shows the difference is the parallel double click, with pauseMs=800 so that the second request arrives while the first is still running:

Table 3. Two parallel requests with the same token and pauseMs=800
Mapping First request to finish Second request to finish

add-book (token)

Shelf Count: 3 Dune Neuromancer Par

Not added This form was already sent. ... Count: 2

add-book-session (tokenSession)

Shelf Count: 3 Dune Neuromancer Par

Shelf Count: 3 Dune Neuromancer Par (the same page, one book added)

With token one of the two requests got the invalid-token page (its count of 2 was read before the first request had added its book, which is a hint of the race that the token removes); with tokenSession both browsers showed the shelf with one new book. The price is memory: the session holds one completed invocation, with its action and value stack, for every token that was accepted and not yet replaced. For a form that creates a large object graph, or on a cluster that replicates sessions, token plus Post/Redirect/Get is the lighter choice; the CDG page lists no extension points for tokenSession.

Table 4. Choosing between token and tokenSession
Question token tokenSession

What does the second request get?

invalid.token, which you map to an error page

The result of the first request, executed again

Does the second request wait?

No, it fails at once (a parallel request can fail before the first has finished)

Yes, it waits for the first one on the session id

What is kept in the session?

Only the token string

The stored invocation of every accepted token

Does it work with a redirect result?

Yes; the repeat is rejected

The stored redirect is sent again

Method filtering

includeMethods, excludeMethods

The same

Good for

Forms where an error message is acceptable and sessions must stay small

Forms where a double click should look like a single one

Named Tokens, Several Forms and Several Tabs

s:token accepts a name attribute. The interceptor learns the name from the hidden struts.token.name field, so nothing else has to be configured. The Bookshelf form add-form-named.jsp posts to add-book with a token named shelfToken:

<s:form action="add-book" method="post" theme="simple">
  <s:textfield name="title"/>
  <s:token name="shelfToken"/>
  <s:submit value="Add"/>
</s:form>

The HTML contained <input type="hidden" name="struts.token.name" value="shelfToken" /> followed by the field shelfToken, and the first post with those two values added the book while the second was invalid, exactly as with the default name. The names exist because the session holds one value per name. When the default name is used by two forms of one session, the second rendering overwrites the first value, and the first form can no longer be accepted:

GET add-form.action   token = KLZ1S2NS...   (form of tab 1)
GET add-form.action   token = ARZMTG36...   (form of tab 2, replaces the first)
POST add-book.action title=Tab1 token=KLZ1S2NS...  Not added This form was already sent. ... Count: 3
POST add-book.action title=Tab2 token=ARZMTG36...  Shelf Count: 4 Dune Neuromancer Named Tab2

Two ways avoid the surprise: use a distinct token name for every form that can be open at the same time (the name of the action is a good choice), or accept that the last rendered form wins and show the invalid-token message in a way that lets the user simply reload the form. A token in a form that is rendered by a different action than the one that processes it, as in the Bookshelf, is not a problem: the token belongs to the session and the name, not to the form.

Tokens, CSRF, SameSite and Fetch Metadata

Struts has no interceptor called a CSRF interceptor. The synchronizer token of this page is the classic defence against cross-site request forgery, because an attacker’s page can make the victim’s browser send a POST with the session cookie but cannot read the token from the form. The token interceptor was designed for the double click, though, and it protects only the actions that list it and only if every protected form carries s:token. The 7.4.0 distribution adds two other layers, one in the framework and one in the container, and none of the three is complete on its own:

Table 5. CSRF defences available to a Struts 7.4.0 application
Layer What it does Limits (observed)

token or tokenSession plus s:token

A request is processed only with a valid per-session token that is removed when used

Per action, opt-in; a forged POST to the protected add-book with the victim’s cookie and no token was rejected (invalid token, shelf unchanged), while the same forged POST to the unprotected add-plain added a book

SameSite on the session cookie

The browser does not send the cookie on cross-site requests (Strict) or sends it only on top-level navigations (Lax)

Set in the container, not in Struts; Tomcat’s Rfc6265CookieProcessor has sameSiteCookies; the Bookshelf META-INF/context.xml sets strict and the response carried Set-Cookie: JSESSIONID=...; Path=/g9token; HttpOnly; SameSite=Strict

fetchMetadata interceptor (in defaultStack in 7.4.0)

Rejects requests whose Sec-Fetch-* headers say they were not made by the application’s own pages

The 7.4.0 policy lets a cross-site request through when Sec-Fetch-Mode is navigate, whatever the method: a forged cross-site POST with Sec-Fetch-Site: cross-site, Sec-Fetch-Mode: navigate and Sec-Fetch-Dest: document reached add-plain and added a book

The conclusion is the one that the OWASP cheat sheet draws for any framework: use more than one layer. Protect every state-changing action with a token (token, or a custom interceptor that applies it to a whole package, see the custom interceptors page of this section), set SameSite (Lax at least) on the session cookie, keep fetchMetadata enabled as an extra layer and do not treat any one of them as proof. The security hardening page of this section covers the rest of the interceptor family (csp, coop, coep) and the Jakarta side of the problem is on the JSP security page.

Post/Redirect/Get as the Complement

A token turns the repeated POST into an error or a second look at the same page. The pattern that removes the repeat at its root is to answer the POST with a redirect to a page that is safe to reload. The browser then holds a GET in its history, and reloading it asks nothing about "resending data". In Struts that is a result of type redirect or redirectAction on the action that changes data, and a second, read-only action that shows the result. The combination (token for the double click, redirect for the reload) is the recommended one. The result types are explained on the results page; note that tokenSession re-executes a stored redirect result, so the repeated request is redirected again too.

The execAndWait Interceptor and Wait Pages

A slow action has the opposite problem. There is nothing to stop; the user waits, perhaps for minutes, while the browser shows a spinner and a proxy may time out. The execAndWait interceptor (class org.apache.struts2.interceptor.ExecuteAndWaitInterceptor) starts the action in a background thread, answers the request immediately with the result named wait, and lets the wait page reload itself until the job is done, at which point the reload gets the real result. The Core Developer Guide describes it as "great for running long-lived actions in the background while showing the user a nice progress meter".

sequenceDiagram participant B as Browser participant E as execAndWait participant P as Background thread participant A as ImportAction B->>E: GET import-validated.action E->>P: no job in the session, start one P->>A: execute(), four steps of 400 ms E-->>B: result wait, wait.jsp shows step 0 of 4 B->>E: refresh after 1 second E-->>B: job running, result wait, step 1 of 4 B->>E: refresh E-->>B: job running, result wait, step 3 of 4 P-->>E: job done, result success B->>E: refresh E-->>B: job done, result success, import-done.jsp

The rules from the Core Developer Guide, each of which the Bookshelf confirmed:

  • The interceptor is registered as execAndWait in struts-default.xml but is part of no default stack, and "it must be the last interceptor in the stack" in the sense of the next section, because the background thread runs only the action.

  • It works per session and action name. While a job runs, every further request of the same session for the same action name, whatever its parameters, gets the wait result and joins the same job. A second request with millis=999 for an import that was started with millis=400 simply showed the running job (step 0 of 4), and a request from another session started its own job: afterwards the shelf held two imported entries, one per session.

  • While the job runs, the action instance of the job is pushed on the value stack, so the wait page can print the progress of the action (progress, total) with the ordinary tags.

  • When a request finds the job done, the interceptor removes the job from the session and returns the action’s own result code; an exception thrown in the background is rethrown on that request.

  • Because the action runs in another thread, ActionContext (a thread-local) is not available in it. Needing the session, the action implements SessionAware, which in Struts 7 is org.apache.struts2.action.SessionAware with the method withSession(Map) (it was setSession in Struts 2.x).

  • The thread is named after the action, for example search under the name search_background-process in 7.4.0.

The Parameters

Table 6. Parameters of execAndWait
Parameter Default Meaning

threadPriority

Thread.NORM_PRIORITY

Priority of the background thread

delay

none

Milliseconds to wait before the wait result is returned for the first request; if the job finishes in that time, the user gets the final result at once

delaySleepInterval

100

How often, in milliseconds, the interceptor wakes to see whether the job is already done; used only with delay

excludeMethods, includeMethods

none

As for every MethodFilterInterceptor; executeAndWaitStack excludes input,back,cancel

delay is the feature for search-like actions of widely varying duration: with a delay of 2000 ms, fast searches show their results at once and only the slow ones show a wait page. The Bookshelf search action (the import job with execAndWait last and a delay of 1000 ms, checked every 50 ms) gave the two behaviours with the same mapping:

<!-- execAndWait last, after the default stack (the layout of the Core Developer Guide example) -->
<interceptor-stack name="searchStack">
    <interceptor-ref name="defaultStack"/>
    <interceptor-ref name="execAndWait">
        <param name="delay">1000</param>
        <param name="delaySleepInterval">50</param>
    </interceptor-ref>
</interceptor-stack>

<action name="search" class="org.example.bookshelf.ImportAction">
    <interceptor-ref name="searchStack"/>
    <result name="wait">/WEB-INF/views/wait.jsp</result>
    <result>/WEB-INF/views/import-done.jsp</result>
</action>
GET search.action?millis=100     (the job takes 4 x 100 ms, under the 1000 ms delay)
 Done after 4 steps: imported 4 books                       (no wait page was shown)
GET search.action?millis=600     (the job takes 4 x 600 ms)
 Please wait: step 1 of 4 Refresh now                       (wait result after the delay)
GET search.action?millis=600     (after the job finished)
 Done after 4 steps: imported 4 books

The Stack Layout and the Built-in executeAndWaitStack

The Core Developer Guide examples put execAndWait after a complete stack (completeStack in the page, which corresponds to defaultStack), and the Bookshelf searchStack does the same. The reason is the one visible in the code of StrutsBackgroundProcess: the background thread calls ActionInvocation.invokeActionOnly(), which runs the action method and nothing else. Parameters, the session, conversion and validation therefore have to be done by the interceptors above execAndWait, in the request thread, before the job starts.

struts-default.xml also contains a "sample execute and wait stack", executeAndWaitStack, that wraps the default stack in two references: execAndWait, defaultStack, execAndWait, both with excludeMethods set to input,back,cancel. The idea is the one of the Struts 2 books, to validate first and run later, but in Struts 7.4.0 neither reference sets executeAfterValidationPass, a property of the interceptor that the Core Developer Guide does not list. Without it the first reference starts the job before defaultStack has run, so the action starts with no parameters and no session. The Bookshelf import-books mapping uses the built-in stack, and its action is SessionAware and reads millis:

GET import-books.action?millis=100         step 0 of 4 (wait)
GET import-books.action?millis=100         (1.5 seconds later) step 2 of 4, still running: millis was ignored, 600 ms per step
... the request that finds the job done: HTTP Status 500
    java.lang.NullPointerException: Cannot invoke "java.util.Map.put(Object, Object)" because "this.session" is null
        at org.example.bookshelf.ImportAction.execute(ImportAction.java:39)

The remedy is the same stack with executeAfterValidationPass set to true on both references. The first reference then lets the request through defaultStack (the first pass), and the second one starts the job (the second pass), with everything populated. The Bookshelf validatedWaitStack is that layout:

<!-- the same layout as the built-in executeAndWaitStack, with executeAfterValidationPass on both -->
<interceptor-stack name="validatedWaitStack">
    <interceptor-ref name="execAndWait">
        <param name="executeAfterValidationPass">true</param>
    </interceptor-ref>
    <interceptor-ref name="defaultStack"/>
    <interceptor-ref name="execAndWait">
        <param name="executeAfterValidationPass">true</param>
    </interceptor-ref>
</interceptor-stack>

<action name="import-validated" class="org.example.bookshelf.ImportAction">
    <interceptor-ref name="validatedWaitStack"/>
    <result name="wait">/WEB-INF/views/wait.jsp</result>
    <result>/WEB-INF/views/import-done.jsp</result>
</action>

With that stack millis=100 was honoured (the second request, 1.5 seconds later, was Done after 4 steps: imported 4 books), the session was set and the import wrote lastImport into it. The simplest rule is the documented one: put a single execAndWait last, after defaultStack, and use the built-in executeAndWaitStack only after reading this section. The execAndWait entry of the Interceptors page of this section (Interceptors) lists the built-in stacks next to it.

Writing the Wait Page

The wait result is an ordinary result, mapped by the name wait. Its page has one job: ask for the same action again after a pause. The Core Developer Guide example is a JSP with a meta refresh whose target is <s:url includeParams='all' />. The Bookshelf page prints the progress as well:

<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" %>
<%@ taglib prefix="s" uri="/struts-tags" %>
<!DOCTYPE html>
<html><head>
<title>Please wait</title>
<s:url var="again" includeParams="all" escapeAmp="false"/>
<meta http-equiv="refresh" content="1;url=<s:property value='#again'/>"/>
</head><body>
<p>Please wait: step <s:property value="progress"/> of <s:property value="total"/></p>
<p><a href="<s:property value='#again'/>">Refresh now</a></p>
</body></html>

Two details of the page were found by running it. includeParams="all" copies every request parameter (also those of a POST) into the URL, which is what a refresh needs, but s:url escapes & as & and s:property escapes it again: without escapeAmp="false" the first version of the page wrote millis=400&amp;struts.token.name=token into the meta tag, so the refresh URL held a literal & and the token never arrived (every refresh was an invalid token). The second detail is the refresh period: the example of the guide uses 5 seconds, the Bookshelf uses 1 second so that the progress can be followed, and a real application picks a value in proportion to the expected duration, and keeps the link for users with the refresh disabled.

The Built-in Wait Page

If the action maps no wait result, the interceptor does not fail. It logs a warning ("no result named 'wait' is available. Defaulting to a plain built-in wait page") and renders /org/apache/struts2/interceptor/wait.ftl with a FreeMarker result, then returns Action.NONE. The Core Developer Guide notes that this works only when FreeMarker is present, and that a page of your own is "generally a good thing to do anyway". The Bookshelf mapping import-default-wait omits the result and answered:

<html>
    <head>
        <meta http-equiv="refresh" content="5;url=/g9token/import-default-wait.action;jsessionid=F3D4467F8435AF8C620C7ED4F2394A1D"/>
    </head>
    <body>
        Please wait while we process your request...
        <p>
        This page will reload automatically and display your request when it is completed.
        </p>
    </body>
</html>

The page refreshes after 5 seconds, passes the session id in the URL, and sends no request parameters (the millis=500 of the first request is not in the URL). That is enough because the job is found by session and action name, but an application that needs a progress bar, a localized text or a cancel button writes its own wait result.

execAndWait Together with a Token

A slow job behind a form wants both guards: the token to stop a second submission of the form, execAndWait to keep the user informed. The Bookshelf stack tokenWaitStack puts token between defaultStack and execAndWait:

<!-- token before execAndWait -->
<interceptor-stack name="tokenWaitStack">
    <interceptor-ref name="defaultStack"/>
    <interceptor-ref name="token"/>
    <interceptor-ref name="execAndWait"/>
</interceptor-stack>

Every refresh passes through token again. execAndWait helps: in the branch that returns the wait result it reads the token from the request and puts it back into the session (TokenHelper.setSessionToken), so that the next refresh, which carries the same token, is valid. The wait page must therefore send the token with each refresh, which includeParams="all" does with struts.token.name and token:

POST import-token.action millis=400 struts.token.name=token token=<tok>
 Please wait: step 0 of 4
  <meta http-equiv="refresh" content="1;url=/g9token/import-token.action?millis=400&amp;struts.token.name=token&amp;token=2D39...">
GET  that URL, four times, 0.5 seconds apart       step 1, step 2, step 3, Done after 4 steps: imported 4 books
GET  that URL once more                            Not added This form was already sent. ...
POST import-token.action (token used, then GET import-token.action with no parameters)
 Not added This form was already sent. ...

The job ran once. After it completed the token was spent, so a further reload of the last refresh URL, or a plain visit to the action without parameters, was an invalid token: the user cannot start a second job with the same form, and cannot get to the result page again by the back button either.

One Job Per Browser Tab, and ExecutorProvider

The job is keyed by action name alone, so a second browser tab of the same session joins the first tab’s job instead of starting its own (the transcript above with millis=999 is that case). The Core Developer Guide documents a way to give each tab its own job: subclass ExecuteAndWaitInterceptor, override getBackgroundProcessName(ActionProxy) and append the token (the page’s example is TokenizedExecuteAndWaitInterceptor, which uses TokenHelper.getToken()), and register it. It adds two caveats of its own. A job is removed from the session only when a request finds it done, so each abandoned run leaves a job and its action in the session; with the action-name key that is at most one per action, with a per-tab key it grows without limit. And the wait page must send the value that forms the key, with includeParams="all" together with the token interceptor, or every refresh starts another job. The issue behind the feature, WW-1742 ("new token associated execute and wait interceptor"), lists 7.4.0 as its fix version.

Since Struts 6.2.0 the thread that runs the job comes from an ExecutorProvider (org.apache.struts2.interceptor.exec.ExecutorProvider). The default implementation is StrutsExecutorProvider; a different executor, for example one with a bounded queue or a managed thread pool, is installed with a bean and a constant:

<bean type="org.apache.struts2.interceptor.exec.ExecutorProvider" name="myExecutor"
      class="com.company.MyExecutorProvider" />
<constant name="struts.executor.provider" value="myExecutor" />

When the executor is shut down (the interceptor’s destroy() shuts it down) or missing, the interceptor logs a warning and runs the action in the request thread instead. Specialised jobs can also extend BackgroundProcess and override getNewBackgroundProcess() in a subclass of the interceptor to acquire and release resources in beforeInvocation() and afterInvocation(); the default beforeInvocation() binds the ActionContext of the invocation to the new thread and afterInvocation() clears it.

Pitfalls

Table 7. Token and wait page pitfalls, their symptoms and fixes (rows marked "observed" were reproduced on Tomcat 11.0.26 with Struts 7.4.0)
Symptom Cause Fix

The form is always reported as already sent, also the first time (observed)

No s:token in the form, or the request has no session cookie, or the token name field is missing; a request without a token is an invalid token

Put s:token inside the form that posts to the protected action; check that the cookie is sent

Nothing handles invalid.token: the user gets an error page instead of the message

The result name is not mapped

Map invalid.token in the package global-results or in the action

The error message is English for a Spanish browser (observed)

token runs before defaultStack, so i18n has not chosen the locale

Put token after defaultStack, or accept the default locale for that message

A form that was open in one tab fails after the form was opened in another tab (observed)

One token per name and session; the second rendering replaced the first

A distinct s:token name for every form that can be open together

The parallel double click gives one error page and one success page

token rejects the second request; the user sees an error although the first succeeded

Use tokenSession, or redirect after the POST and map invalid.token to a page that shows the current state

The session grows with the number of form posts

tokenSession keeps one stored invocation per accepted token

Use token for large results, and clear the session attributes named struts.tokens.<name> when the flow ends

IllegalArgumentException ("Error creating HttpSession due response is committed") when the token tag is rendered

The session did not exist and the response was already committed

Add createSession before the rendering, or create the session in an earlier action

An action behind token still runs twice on a double click

The token is not removed because the interceptor sits in a stack that the action does not reference, or the action method is in excludeMethods

Print the stack the action really uses; check excludeMethods and includeMethods names against the Java method names

The action behind execAndWait gets no parameters or session; HTTP 500 on the last refresh (observed with the built-in executeAndWaitStack)

The first execAndWait starts the job before defaultStack, and the job runs only the action

Put one execAndWait after defaultStack, or set executeAfterValidationPass to true on both references of the built-in layout

ActionContext.getContext().getSession() is null or throws in the job

ActionContext is a thread-local and the job runs in its own thread

Implement SessionAware; hand the values the job needs to the action before the job starts

The wait page refreshes with a garbled URL, and the refresh loses the token (observed)

s:url escaped &, then s:property escaped it again

escapeAmp="false" on s:url, or print the URL with escapeHtml="false"

A second tab shows the progress of the first tab’s job (observed)

The job is keyed by session and action name

Use a per-tab key with getBackgroundProcessName and the token, and accept the leak caveat

The wait page keeps showing the default text

No wait result mapped (the built-in FreeMarker page is used, 5 second refresh, no parameters)

Map your own wait result

A slow job is lost on a server restart or on another cluster node

The job is a thread plus a session attribute; it is not serializable

Use a real job queue for work that must survive; execAndWait is for requests that outlive a page load

The token is posted to another host (observed with action="%{#parameters.target[0]}" and a target of https://evil.example/x)

The form action was taken from a request parameter, and s:form writes an unmapped value into the HTML as it is

A fixed action name in s:form, or a getter that maps a key to an allowlisted action name

A forged cross-site POST still reaches an action that has no token (observed)

fetchMetadata allows cross-site navigate requests, and the action is not protected

A token on every state-changing action, SameSite cookies, and fetchMetadata only as an extra layer

What Changed Recently

Table 8. Token and execAndWait changes by Struts version (from the Core Developer Guide pages, the Javadoc and the 7.4.0 jar)
Version Change

Struts 2.x and 6.x

The interceptors lived in com.opensymphony.xwork2.interceptor and org.apache.struts2.interceptor; SessionAware had setSession(Map) in org.apache.struts2.interceptor

6.2.0

ExecutorProvider for the thread of execAndWait (the struts.executor.provider constant and StrutsExecutorProvider as default)

7.0.0

Packages moved to org.apache.struts2.*: org.apache.struts2.interceptor.TokenInterceptor, TokenSessionStoreInterceptor, ExecuteAndWaitInterceptor, org.apache.struts2.util.TokenHelper; SessionAware is org.apache.struts2.action.SessionAware with withSession; @StrutsParameter governs which setters take request values, including the token names read by the interceptors from the request parameter map

7.4.0

The per-tab key through getBackgroundProcessName(ActionProxy) is documented on the Core Developer Guide page (issue WW-1742, fix version 7.4.0); the 7.4.0 TokenHelper logs a mismatch of the form token against the session token, and adds the session token in development mode

The table is built from what the guides state and what the 7.4.0 classes contain; the release notes of the versions in between are listed on the Struts site, and the What’s Changed & Migration page of this section summarizes them.

Where the Books Are Outdated

The books that treat this topic (the Struts 1 and Struts 2 books of the section) teach the synchronizer token and the background job correctly as ideas. The details changed.

Table 9. Token and wait page material in the books and what is current
In the books Struts 7.4.0

Struts 1: saveToken(request) in the Action and isTokenValid(request, true) to check and reset it

No such methods; the form carries s:token, the stack carries token or tokenSession, and the check is done by the interceptor

Struts 2 book: TokenInterceptor and TokenSessionStoreInterceptor in com.opensymphony.xwork2.interceptor

org.apache.struts2.interceptor; the names token and tokenSession are unchanged

execAndWait placed last in a stack that includes the book’s paramsPrepareParamsStack or the old defaultStack

The documented rule still holds; the built-in executeAndWaitStack of 7.4.0 does not follow it without executeAfterValidationPass

SessionAware with setSession(Map) from org.apache.struts2.interceptor

org.apache.struts2.action.SessionAware with withSession(Map)

A wait page with <s:url includeParams="all"/> inside meta

Still the pattern, with the escaping caveat of the page above

Wait pages that refresh with an Ajax call or a Dojo widget

Dojo and the ajax theme are not part of Struts 7; a meta refresh, a script with fetch or a JSON action (see the JSON page of this section) does the same

Tokens as the answer to cross-site request forgery

Still valid, but the framework now has fetchMetadata and containers have SameSite cookies: use all of them

Inconsistencies in the Official Documentation

  • The Execute and Wait Interceptor page names the thread <actionName>BackgroundProcess (for example searchBackgroundProcess); the 7.4.0 code names it <actionName>_background-process.

  • The same page’s wait-page example declares the tag library as uri="/struts" (the URI is /struts-tags) and has a stray closing pre tag after the JSP, and the built-in executeAndWaitStack that it implies works only with executeAfterValidationPass, which no guide lists.

  • The Token Session Interceptor examples have unbalanced quotes and a wrong closing tag (<interceptor-ref name="tokenSession/>); the name tokenSession stands for the class TokenSessionStoreInterceptor.

  • The Token Interceptor page says that there are no parameters, which is true of the interceptor and misleading for its base class: excludeMethods and includeMethods apply.

  • The built-in wait page refreshes without request parameters; the guides do not say so.

Summary

  • A form is protected by s:token (the form) and token or tokenSession (the stack); the token is one random value per name and session, removed when used.

  • An invalid token, including no token, gives the result invalid.token, which must be mapped; the message key is struts.messages.invalid.token. Put the interceptor after defaultStack for a localized message.

  • tokenSession shows the first answer again, serializes parallel requests and keeps the invocation in the session.

  • One token name per form that can be open at the same time.

  • A token, SameSite and fetchMetadata are layers of a CSRF defence; none is enough alone.

  • execAndWait runs the action in a background thread, answers with wait, joins by session and action name, needs SessionAware, and belongs after defaultStack; the built-in executeAndWaitStack needs executeAfterValidationPass.

  • A wait page refreshes with s:url includeParams="all" escapeAmp="false", and a token keeps working across the refreshes because execAndWait stores it back in the session.