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.
| Cause | What happens on the server | Defence |
|---|---|---|
The user double-clicks the submit button |
Two identical |
|
The user reloads the page that answered a |
The browser asks "resend the form data?" and repeats the request with the same body |
A token makes the repeat invalid; redirecting after the |
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 |
|
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 |
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 |
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.
| URL | What it demonstrates | Interceptors |
|---|---|---|
|
A form with a title field and |
|
|
Adds a book with no protection at all (the control) |
|
|
Adds a book once per token |
|
|
The same with |
|
|
Adds a book once and shows the first answer again on a repeat |
|
|
One wildcard mapping, the token checked for |
|
|
A slow import of four books, with a wait page |
|
|
The same import with a stack that works (see The Stack Layout and the Built-in executeAndWaitStack) |
|
|
The same job with |
|
|
The slow job behind a token |
|
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 namestruts.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 constantTokenHelper.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,
TokenHelperlogs "Error creating HttpSession due response is committed" and throws; thecreateSessioninterceptor, placed before the page is rendered, orrequest.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.
Source: Struts Javadoc — TokenHelper
How a Token Travels
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 theInvocationSessionStore, 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.tokenas 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:
| Mapping | First request to finish | Second request to finish |
|---|---|---|
|
|
|
|
|
|
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.
| Question | token |
tokenSession |
|---|---|---|
What does the second request get? |
|
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 |
|
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>
Source: Tag Developers Guide — token tag
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:
| Layer | What it does | Limits (observed) |
|---|---|---|
|
A request is processed only with a valid per-session token that is removed when used |
Per action, opt-in; a forged |
|
The browser does not send the cookie on cross-site requests ( |
Set in the container, not in Struts; Tomcat’s |
|
Rejects requests whose |
The 7.4.0 policy lets a cross-site request through when |
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".
The rules from the Core Developer Guide, each of which the Bookshelf confirmed:
-
The interceptor is registered as
execAndWaitinstruts-default.xmlbut 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
waitresult and joins the same job. A second request withmillis=999for an import that was started withmillis=400simply 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 implementsSessionAware, which in Struts 7 isorg.apache.struts2.action.SessionAwarewith the methodwithSession(Map)(it wassetSessionin Struts 2.x). -
The thread is named after the action, for example
searchunder the namesearch_background-processin 7.4.0.
The Parameters
| Parameter | Default | Meaning |
|---|---|---|
|
|
Priority of the background thread |
|
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 |
|
100 |
How often, in milliseconds, the interceptor wakes to see whether the job is already done; used only with |
|
none |
As for every |
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&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&struts.token.name=token&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
| Symptom | Cause | Fix |
|---|---|---|
The form is always reported as already sent, also the first time (observed) |
No |
Put |
Nothing handles |
The result name is not mapped |
Map |
The error message is English for a Spanish browser (observed) |
|
Put |
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 |
The parallel double click gives one error page and one success page |
|
Use |
The session grows with the number of form posts |
|
Use |
|
The session did not exist and the response was already committed |
Add |
An action behind |
The token is not removed because the interceptor sits in a stack that the action does not reference, or the action method is in |
Print the stack the action really uses; check |
The action behind |
The first |
Put one |
|
|
Implement |
The wait page refreshes with a garbled URL, and the refresh loses the token (observed) |
|
|
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 |
The wait page keeps showing the default text |
No |
Map your own |
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; |
The token is posted to another host (observed with |
The form |
A fixed action name in |
A forged cross-site |
|
A token on every state-changing action, |
What Changed Recently
| Version | Change |
|---|---|
Struts 2.x and 6.x |
The interceptors lived in |
6.2.0 |
|
7.0.0 |
Packages moved to |
7.4.0 |
The per-tab key through |
Source: Apache JIRA — WW-1742
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.
| In the books | Struts 7.4.0 |
|---|---|
Struts 1: |
No such methods; the form carries |
Struts 2 book: |
|
|
The documented rule still holds; the built-in |
|
|
A wait page with |
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 |
Tokens as the answer to cross-site request forgery |
Still valid, but the framework now has |
Inconsistencies in the Official Documentation
-
The Execute and Wait Interceptor page names the thread
<actionName>BackgroundProcess(for examplesearchBackgroundProcess); 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 closingpretag after the JSP, and the built-inexecuteAndWaitStackthat it implies works only withexecuteAfterValidationPass, which no guide lists. -
The Token Session Interceptor examples have unbalanced quotes and a wrong closing tag (
<interceptor-ref name="tokenSession/>); the nametokenSessionstands for the classTokenSessionStoreInterceptor. -
The Token Interceptor page says that there are no parameters, which is true of the interceptor and misleading for its base class:
excludeMethodsandincludeMethodsapply. -
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) andtokenortokenSession(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 isstruts.messages.invalid.token. Put the interceptor afterdefaultStackfor a localized message. -
tokenSessionshows 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,
SameSiteandfetchMetadataare layers of a CSRF defence; none is enough alone. -
execAndWaitruns the action in a background thread, answers withwait, joins by session and action name, needsSessionAware, and belongs afterdefaultStack; the built-inexecuteAndWaitStackneedsexecuteAfterValidationPass. -
A wait page refreshes with
s:url includeParams="all" escapeAmp="false", and a token keeps working across the refreshes becauseexecAndWaitstores it back in the session.
References
-
Core Developer Guide — Token Interceptor, Internationalization Note
-
Core Developer Guide — Execute and Wait Interceptor, Example code 1
-
Core Developer Guide — Execute and Wait Interceptor, Example code 2
-
Core Developer Guide — Execute and Wait Interceptor, Example code 3
-
Core Developer Guide — Execute and Wait Interceptor, One background process per browser tab
-
Core Developer Guide — Execute and Wait Interceptor, Using ExecutorProvider
-
Core Developer Guide — Fetch Metadata Interceptor, Description
-
Apache JIRA — WW-1742, new token associated execute and wait interceptor