XSRF, RPC & Server Security
|
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 GWT 2.13.1 (released 2026-06-19), gwt-maven-plugin 1.3.0 (net.ltgt.gwt.maven), JDK 21 running the compiler and the server with -sourceLevel 17, the Jakarta Servlet 6 server of the Bookshelf scenario (Jetty 12.1.10, EE 10), Spring Security 7.1.1 for the REST examples and the gwt-user and gwt-servlet-jakarta 2.13.1 jars and sources, checked on 2026-10-09.
Security in a GWT application is split across three kinds of code in a way that matters.
The client code (everything compiled to JavaScript) is public: anyone can download, read and modify it, so it can hold no secret and enforce no rule.
The shared code (DTOs, service interfaces) defines what may cross the wire, and with GWT-RPC the build step turns that definition into the serialisation policy file that the server enforces.
The server code is the only place where authentication, authorisation, input validation and size limits count.
This page covers the server side of that split: the tokens that tie a call to a session (XSRF), what a hostile client can send to a GWT-RPC servlet, how to check who is calling and what they may do, and what must never be reachable from outside, notably the Super Dev Mode code server.
The browser-side injection problem, SafeHtml, SafeUri and Content Security Policy, is the subject of the SafeHtml & XSS page.
Every output below was produced by running the Bookshelf scenario: the curl calls against Jetty 12 on port 8942 hit the GWT-RPC CatalogService, the XsrfTokenServiceServlet and the REST servlet of the scenario plus four small classes of this page (printed below), and the code server runs used gwt:codeserver on port 9942.
Session ids, XSRF tokens, policy hashes and generated file names are replaced by placeholders in the listings, and the only credential in the page is the placeholder changeit-placeholder of a throw-away local realm.
Where a statement comes only from a reference page, the Javadoc or the 2.13.1 source and was not run, the text says so.
The threat model on one page
A GWT application talks to its server over plain HTTP, so it inherits every web threat. The table maps each threat to what GWT gives you, what it does not, and where this page treats it.
| Threat | What GWT provides | What you must add on the server | Section |
|---|---|---|---|
Cross-site request forgery (XSRF/CSRF): another site makes the user’s browser send an authenticated request |
For GWT-RPC: |
The token for non-RPC endpoints (header or form field), |
XSRF protection for GWT-RPC, XSRF protection for RequestBuilder, REST and forms |
JSON hijacking and JSONP data theft |
RPC responses that cannot be loaded with a script tag. |
Top-level JSON objects, |
|
Forged or malicious RPC payload (type confusion, enhanced-class deserialisation) |
The serialisation policy: a whitelist of types per service, enforced on the server. |
Deploy the policy file, do not enable enhanced classes, keep entity graphs out of the service interface. |
|
Calling a service as someone else, or as nobody |
Nothing: a GWT-RPC servlet is an ordinary servlet. |
Container or framework authentication on the URL, authorisation inside every method, object-level checks. |
|
Open proxy, arbitrary file write, oversized upload |
Nothing. |
A fixed upstream, an allow-list, server-chosen file names, size limits, authentication. |
|
Remote control of the development tools |
The code server binds to |
Never change that, never publish its port, never ship it. |
|
Information disclosure through source maps, symbol maps and error pages |
Production output has none of them in the servable part. |
Keep |
Source maps, symbol maps and what a production build leaves behind |
The same-origin policy and what it does not stop
The Developer Guide article on security opens with the same-origin policy: code that runs in a page loaded from site A cannot read data or network resources of site B.
It is why an XMLHttpRequest to another site fails and why GWT-RPC works only against the origin that served the page (or a server that sends the matching CORS headers, see What is CORS?).
The policy protects reading, not sending.
A page on evil.example can still make the browser send a request to bookshelf.example, with that site’s cookies attached, by creating a form, an image or a script tag, and the server cannot tell it from a request made by the real application.
It cannot read the answer, but a request that changes state does not need the answer.
That is XSRF, and the AJAX style makes it worse than in a classic multi-page site: one request is enough to "email the employee data" or "delete the book", with no multi-step navigation that an attacker would have to forge page by page.
The article’s defence, which every mechanism on this page is a variation of, is to make the request prove that the code which sent it can read something only the real application can read: a value from a cookie or from an earlier response of the same origin.
A foreign page cannot read either, so it cannot copy the value into its forged request.
XSRF protection for GWT-RPC
GWT 2.3 added a token mechanism for GWT-RPC. It is built into the servlet base classes, so a project that follows it needs about twenty lines of configuration and no framework. The reference pages are the Developer Guide page "Security: GWT RPC XSRF protection" and the article "Security for GWT applications"; the classes below were read in the 2.13.1 sources and exercised on Jetty.
The classes and annotations
The client-side types live in com.google.gwt.user.client.rpc and are identical in gwt-user and in both servlet jars.
The server-side classes exist twice: com.google.gwt.user.server.rpc in gwt-servlet (javax.servlet) and com.google.gwt.user.server.rpc.jakarta in gwt-servlet-jakarta (jakarta.servlet, since GWT 2.11).
The two annotations stay in the old package and are shared by both flavours.
| Type | Package | Role |
|---|---|---|
|
|
Marker interface (extends |
|
|
The only |
|
|
Implemented by every generated service proxy: |
|
|
A |
|
|
One method, |
|
|
A |
|
|
The RPC interface whose single method, |
|
|
Runtime annotations for types and methods (both |
|
|
The servlet behind |
|
|
Base class of protected servlets. Overrides |
|
|
The ready-made subclass: compares the incoming |
How the token is made and checked
The default implementation is stateless.
XsrfTokenServiceServlet reads the value of the session cookie, computes its MD5 digest and returns the digest as an upper-case hexadecimal string of 32 characters (the sources use Md5Utils.getMd5Digest and StringUtils.toHexString).
XsrfProtectedServiceServlet repeats the computation on each protected call and compares the result with the token that the client sent as the first object of the RPC payload.
The check was confirmed by computing the MD5 digest of the JSESSIONID value of a test session with the operating system’s md5 and comparing it with the token that the servlet returned: they were equal, so the token is a function of the session id and nothing else.
This design has consequences that the Developer Guide leaves implicit and the source makes plain:
-
The security of the token is the security of the session cookie. An attacker who can read the cookie can compute the token, but then has the session anyway. The MD5 digest is used as a fixed one-way mapping here, not as a collision-resistant signature.
-
The token never expires on its own and is not rotated per request. It lives as long as the session id, so it must not be written to logs, URLs or the HTML of a page that is cached by a shared cache.
-
A new session id means a new token. When the container changes the id after login (a session-fixation defence of most containers and of Spring Security), the token fetched before the login no longer matches the cookie, and the next call is refused with
Invalid XSRF token. This follows from the comparison invalidateXsrfToken; renew the token after authentication (see Renewing a token). -
The check needs a session cookie. Without one, the token servlet cannot even answer (see the pitfalls table).
-
The cookie may be
HttpOnly. The server, not the browser’s JavaScript, reads it, and the client receives the token as an RPC result.
The token flow
The diagram shows one page load.
The names are the real ones of the Bookshelf: /bookshelf/xsrf is the XsrfTokenServiceServlet, /bookshelf/catalog is CatalogServiceImpl, and SessionFilter makes sure that a session cookie exists before the first token is requested.
The forged request in steps 10 and 11 carries the victim’s cookie, because the browser attaches it, but not the token, because the attacker’s page cannot read the cookie and cannot call the token servlet cross-origin and read the answer.
Even if the attacker could send the right payload shape, the request has to be a Content-Type: text/x-gwt-rpc POST with an X-GWT-Permutation header, which a cross-site form cannot produce (see the replay discussion on the GWT-RPC page, GWT-RPC).
The token is the real defence, the headers are a weak extra layer.
Server wiring
The scenario’s web.xml declares the cookie name once as a context parameter, so that the token servlet and every protected servlet read the same value, and maps the token servlet next to the catalog servlet:
<context-param>
<param-name>gwt.xsrf.session_cookie_name</param-name>
<param-value>JSESSIONID</param-value>
</context-param>
<filter>
<filter-name>sessionFilter</filter-name>
<filter-class>com.example.bookshelf.server.SessionFilter</filter-class>
</filter>
<filter-mapping>
<filter-name>sessionFilter</filter-name>
<url-pattern>/*</url-pattern>
</filter-mapping>
<servlet>
<servlet-name>catalogServlet</servlet-name>
<servlet-class>com.example.bookshelf.server.CatalogServiceImpl</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>catalogServlet</servlet-name>
<url-pattern>/bookshelf/catalog</url-pattern>
</servlet-mapping>
<servlet>
<servlet-name>xsrfServlet</servlet-name>
<servlet-class>com.google.gwt.user.server.rpc.jakarta.XsrfTokenServiceServlet</servlet-class>
</servlet>
<servlet-mapping>
<servlet-name>xsrfServlet</servlet-name>
<url-pattern>/bookshelf/xsrf</url-pattern>
</servlet-mapping>
Both the init parameter of one servlet and the context parameter work: XsrfProtectedServiceServlet.init and XsrfTokenServiceServlet.init read the servlet’s own ServletConfig first and the ServletContext second, and throw IllegalStateException with the text in the pitfalls table if neither is set.
Both servlets also have a constructor that takes the cookie name, which is how a Spring Boot application registers them without a web.xml (see Server Integration & Deployment).
The filter is three lines, and the page of the scenario that uses it is the reason the first token request works:
public class SessionFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {
((HttpServletRequest) req).getSession(true); // creates JSESSIONID if there is none
chain.doFilter(req, resp);
}
}
The protected servlet is an ordinary servlet whose base class changed:
public class CatalogServiceImpl extends XsrfProtectedServiceServlet implements CatalogService {
@Override
public BookPage getBooks(int start, int length) {
return CatalogRepository.INSTANCE.page(start, Math.min(length, 100));
}
// ...
}
and the shared interface extends the protected marker:
@RemoteServiceRelativePath("catalog")
public interface CatalogService extends XsrfProtectedService {
BookPage getBooks(int start, int length);
Book getBook(long id) throws BookNotFoundException;
Book saveBook(Book book) throws BookNotFoundException;
}
The Async interface does not change.
Because the token service is itself an RPC service, GWT.create(XsrfTokenService.class) also needs its URL set, and the shared module must be inherited by the client (the token types are in gwt-user, nothing extra to inherit).
Client wiring
The client fetches one token at start-up and installs it on every protected proxy. Until the callback fires, no protected call can succeed, so the Bookshelf starts its history handling only afterwards:
public void initXsrf(Runnable onReady) {
XsrfTokenServiceAsync xsrf = GWT.create(XsrfTokenService.class);
((ServiceDefTarget) xsrf).setServiceEntryPoint(GWT.getModuleBaseURL() + "xsrf");
xsrf.getNewXsrfToken(new AsyncCallback<XsrfToken>() {
@Override
public void onSuccess(XsrfToken token) {
((HasRpcToken) catalog).setRpcToken(token); // sent with every later call
onReady.run();
}
@Override
public void onFailure(Throwable caught) {
GWT.log("Cannot get XSRF token", caught);
}
});
}
The generated proxy writes the token as the first object of the payload when getRpcToken() is not null (RemoteServiceProxy.ServiceHelper.start), so the flag in the second position of the payload is 2 instead of 0.
setRpcToken also checks the token’s type against the service’s: if the service interface has a single serialisable RpcToken implementation, the generated proxy refuses any other class with Invalid RpcToken type: expected ... but got ... (the generator’s checkRpcTokenType).
Renewing a token
A token goes stale when the session id changes, typically after login, after a session timeout and re-login, or after the server restarted and lost its sessions.
The proxy reports it as an RpcTokenException, and RpcTokenExceptionHandler is the place to react centrally instead of in every callback:
((HasRpcToken) catalog).setRpcTokenExceptionHandler(new RpcTokenExceptionHandler() {
@Override
public void onRpcTokenException(RpcTokenException exception) {
XsrfTokenServiceAsync xsrf = GWT.create(XsrfTokenService.class);
((ServiceDefTarget) xsrf).setServiceEntryPoint(GWT.getModuleBaseURL() + "xsrf");
xsrf.getNewXsrfToken(new AsyncCallback<XsrfToken>() {
@Override public void onSuccess(XsrfToken t) { ((HasRpcToken) catalog).setRpcToken(t); }
@Override public void onFailure(Throwable c) { /* session really gone: show the login page */ }
});
}
});
This compiled against gwt-user 2.13.1 together with the other client snippets of this page.
When a handler is set, the failing call’s own onFailure is not called for an RpcTokenException (RequestCallbackAdapter.onResponseReceived routes the exception to the handler only), so the handler must either retry the original call or tell the user; a silent handler makes the call vanish.
The retry is the application’s business: remember the last request or ask the user to repeat the action, and retry at most once, or an expired session turns into a loop.
Which methods are protected
AbstractXsrfProtectedServiceServlet.shouldValidateXsrfToken delegates to Util.isMethodXsrfProtected, whose rules the Javadoc lists in order:
| Order | Rule |
|---|---|
1 |
A method annotated |
2 |
A method annotated |
3 |
If the declaring class is annotated |
4 |
If the declaring class (or an interface it extends, because both annotations are |
5 |
With no annotation at all: if the class has a method whose return type is an |
6 |
Otherwise the method is not protected. |
The result is that extending XsrfProtectedService protects everything, which is the safe default, and that the exemption for a public read-only method such as a version check or a login ping must be explicit:
public interface PublicService extends RemoteService {
@NoXsrfProtect
String version(); // harmless, read-only
@XsrfProtect
void changeEmail(String email); // needs the token even though the interface is not protected
}
Note that annotating only the interface is enough for the check, because the rules inspect the method that the servlet is about to invoke and its declaring type; the servlet class does not have to repeat the annotation.
A servlet class that extends RemoteServiceServlet (not XsrfProtectedServiceServlet) ignores all of this: the annotations are only evaluated by AbstractXsrfProtectedServiceServlet.
That is the most common way to end up with a service that the team believes to be protected and that is not.
Customising the check
Three extension points exist, all protected methods of AbstractXsrfProtectedServiceServlet:
-
validateXsrfToken(RpcToken, Method)is where a different token scheme goes: a random token stored in the session, a token bound to the user id, a short-lived signed token. ThrowRpcTokenExceptionto refuse the call. -
shouldValidateXsrfToken(Method)changes which methods are checked, for example to exempt a whole package of read-only methods by name. -
onAfterRequestDeserialized(RPCRequest)is where the check is invoked from. An override in a protected servlet, for logging or per-method authorisation, must callsuper.onAfterRequestDeserialized(rpcRequest), or the token check disappears silently. This is the single most dangerous override in the class.
A custom token type is a class that implements RpcToken (serialisable, public default constructor), annotated on the service interface with @RpcToken.RpcTokenImplementation("com.example.bookshelf.shared.SignedToken") so that the compiler generates a serialiser for that class only.
The Developer Guide shows the same structure with a trivial example.
Nothing else in this page depends on a custom token and the Bookshelf uses the shipped XsrfToken.
Measured: the token flow with curl
The calls below replay the three steps of the diagram with curl against the running Bookshelf.
The permutation header and the policy hashes are placeholders; the session id and the token are placeholders for the real values, which are not printed.
A protected call without a token is refused with a serialised RpcTokenException, delivered with HTTP status 200 as every RPC answer is (the client library turns the //EX prefix into an exception):
POST /bookshelf/catalog (payload: flags 0, no token)
>> 7|0|5|http://localhost:8942/bookshelf/|CATALOG_POLICY_HASH|com.example.bookshelf.shared.CatalogService|getBooks|I|1|2|3|4|2|5|5|0|2|
<< 200 application/json;charset=utf-8
//EX[2,1,["com.google.gwt.user.client.rpc.RpcTokenException/2345075298","Invalid RPC token (XSRF token missing)"],0,7]
The container log of that request held WARNING: catalogServlet: An RpcTokenException was thrown while processing this call. followed by the stack trace.
That answer needs a JVM that can serialise exceptions, which on Java 17 and later means --add-opens java.base/java.lang=ALL-UNNAMED (the Jetty run above used it through MAVEN_OPTS; see the GWT-RPC page and GWT issue 9793).
Without that option the same request answered:
<< 500 Server Error
The call failed on the server; see server log for details
(log) java.lang.reflect.InaccessibleObjectException: Unable to make field private java.lang.String java.lang.Throwable.detailMessage accessible: module java.base does not "opens java.lang" to unnamed module
so on Java 17 and later without the option the browser sees a generic 500 and the RpcTokenExceptionHandler is never called.
The protection itself still works, because the exception is thrown before the method runs; only the explanatory answer is lost.
The token request, with the session cookie that the filter created, and the protected call that carries the token (the first object of the payload, type XsrfToken/4254043109, flag 2):
POST /bookshelf/xsrf
>> 7|0|4|http://localhost:8942/bookshelf/|TOKEN_POLICY_HASH|com.google.gwt.user.client.rpc.XsrfTokenService|getNewXsrfToken|1|2|3|4|0|
<< 200 application/json;charset=utf-8
//OK[2,1,["com.google.gwt.user.client.rpc.XsrfToken/4254043109","<xsrf-token>"],0,7]
POST /bookshelf/catalog
>> 7|2|7|http://localhost:8942/bookshelf/|CATALOG_POLICY_HASH|com.google.gwt.user.client.rpc.XsrfToken/4254043109|<xsrf-token>|com.example.bookshelf.shared.CatalogService|getBooks|I|1|2|3|4|5|6|2|7|7|0|2|
<< 200 application/json;charset=utf-8
//OK[25,2001,7,"C",1,6,3,1999,5,"B",0,4,3,2,2,1,["com.example.bookshelf.shared.BookPage/3107328605","java.util.ArrayList/4159755760","com.example.bookshelf.shared.Book/2103976895","Andrew Hunt","The Pragmatic Programmer","Joshua Bloch","Effective Java"],2,7]
In the request the string table has seven entries, the token object is written as 3|4 (type, then the string <xsrf-token>) before the service name 5 and the method 6, and the flag 2 in the header says "an RPC token is included".
Three more variations were sent with a valid session, to see each branch of validateXsrfToken:
| Request | Answer (all HTTP 200 with the //EX prefix, log level WARNING) |
|---|---|
No token in the payload |
|
A token that is not the digest of the current cookie |
|
A correct token, but sent without the session cookie |
|
No cookie on the token request |
HTTP |
The last row is the reason for the SessionFilter: the token servlet throws its RpcTokenException from a method whose signature does not declare it, so the RPC engine reports an unexpected exception and the client sees a 500, not a catchable token error.
XSRF protection for RequestBuilder, REST and forms
RequestBuilder, JSON and REST endpoints (see "RequestBuilder, JSON & XML" and REST & JSON Libraries), and FormPanel submissions bypass the GWT-RPC machinery, so the RPC tokens do not apply to them.
Any endpoint that authenticates with a cookie needs its own protection, and the available mechanisms rank like this.
| Mechanism | How it works | Limits |
|---|---|---|
A token in a header, checked against a cookie or the session (double submit or synchroniser token) |
The client reads a token and sends it in |
The server must compare the header to a value only the application can know. A header that is merely present is weak, because a permissive CORS configuration lets it through. |
A token in a hidden form field ( |
For |
A multipart request is parsed by the container before the application sees the field, so size limits must be set first. |
|
The browser does not send the cookie on cross-site sub-requests. MDN documents the attribute and the defaults of current browsers. |
Defence in depth, not a replacement: it does not help same-site attackers (a sibling sub-domain) or old browsers, and |
Not using cookies for authentication (a bearer token in a header) |
A forged request cannot carry a header the attacker’s page cannot read. |
The token is then reachable by XSS and must be stored with care. Shifts the problem to the token lifecycle. |
|
Prevents the cheap forgery with an image tag or a link. |
Not a defence against a forged form post. |
Spring Security CSRF tokens
Spring Security protects unsafe HTTP methods (POST, PUT, PATCH, DELETE) with the CsrfFilter by default.
The browser-side pattern that suits a GWT client is the cookie variant, which the reference documentation describes: CookieCsrfTokenRepository writes a cookie named XSRF-TOKEN that JavaScript is allowed to read when it is created with withHttpOnlyFalse(), and the filter accepts the value back in the request header X-XSRF-TOKEN or the request parameter _csrf.
The names were checked by asking the 7.1.1 classes directly:
header=X-XSRF-TOKEN parameter=_csrf tokenLength=36
session repo header=X-CSRF-TOKEN parameter=_csrf
The first line is CookieCsrfTokenRepository.withHttpOnlyFalse().generateToken(null), the second the default HttpSessionCsrfTokenRepository, which keeps the token in the session and expects the header X-CSRF-TOKEN.
A configuration for the Bookshelf’s JSON and upload endpoints that leaves the GWT-RPC URLs to the GWT mechanism looks like this:
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
SecurityFilterChain api(HttpSecurity http) throws Exception {
http
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/**", "/upload/**").hasRole("LIBRARIAN")
.anyRequest().authenticated())
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse())
.csrfTokenRequestHandler(new CsrfTokenRequestAttributeHandler())
// GWT-RPC has its own token mechanism and is excluded here, on purpose
.ignoringRequestMatchers("/bookshelf/catalog", "/bookshelf/xsrf"));
return http.build();
}
}
This configuration compiled against Spring Security 7.1.1 and Spring Framework 7.0.9.
The request-handler line matters for a browser client.
Since Spring Security 6 the default handler, XorCsrfTokenRequestAttributeHandler, masks the token on every response as a protection against the BREACH compression attack, while the cookie holds the plain value; the reference documentation’s section on single-page applications explains that JavaScript then only has the plain value and a handler that resolves it is needed.
CsrfTokenRequestAttributeHandler is the plain handler, which accepts the cookie value as the header value; the documentation’s own SPA example delegates to the masking handler for rendered pages and to the plain one for headers, which keeps the BREACH protection for server-rendered tokens.
Which of the two to pick depends on whether the same application renders pages with embedded tokens; read the reference page before copying either line.
The ignoringRequestMatchers line is a judgement call: it is correct only because the two GWT-RPC URLs enforce their own token, and it must be re-read whenever a service is added.
The alternative, protecting RPC with Spring’s CSRF token as well, works by adding a custom RpcRequestBuilder that sets the header, but then two token systems run side by side, and the GWT one does not go away.
Everything about the Spring Security filter chain, user stores and method security is on the backend side of this wiki: Spring Security; and the same CSRF tokens in server-rendered pages are covered in Thymeleaf with Spring Security.
The behaviour of the Spring filter was not run end to end in the scenario (no Spring Boot application was started for this page); the class names, header and parameter names and the default repository were verified as shown, and the filter’s contract is the documented one.
The GWT client side
RequestBuilder reads the cookie with Cookies.getCookie and sets the header; setIncludeCredentials(true) is needed only for cross-origin requests that must send cookies.
A FormPanel cannot set headers, so the token travels in a Hidden field named _csrf:
void postJson(String json, RequestCallback cb) throws RequestException {
RequestBuilder rb = new RequestBuilder(RequestBuilder.POST, URL.encode("/api/loans"));
rb.setHeader("Content-Type", "application/json");
String csrf = Cookies.getCookie("XSRF-TOKEN");
if (csrf != null) {
rb.setHeader("X-XSRF-TOKEN", csrf);
}
rb.sendRequest(json, cb);
}
void prepare(FormPanel form) {
form.setMethod(FormPanel.METHOD_POST);
form.setEncoding(FormPanel.ENCODING_MULTIPART);
form.setAction("/upload/cover");
String csrf = Cookies.getCookie("XSRF-TOKEN");
form.add(new Hidden("_csrf", csrf == null ? "" : csrf));
}
Both methods compiled against gwt-user 2.13.1.
Two details from the RequestBuilder, JSON & XML page apply: any setHeader call removes the default form content type of RequestBuilder, which is why the example sets Content-Type itself, and a FormPanel post is answered into a hidden frame, so the server’s 403 is visible only through the submit-complete event.
The same idea without a framework
The mechanism of the cookie repository fits in a servlet filter.
The one below is the lab version used for this page: it issues a random XSRF-TOKEN cookie that the page may read and requires the same value in the X-XSRF-TOKEN header on every method that is not GET, HEAD or OPTIONS.
It exists to show the mechanism and to be run; in an application with Spring Security the framework’s filter replaces it.
public class CsrfCookieFilter implements Filter {
static final String COOKIE = "XSRF-TOKEN";
static final String HEADER = "X-XSRF-TOKEN";
private static final Set<String> SAFE = Set.of("GET", "HEAD", "OPTIONS");
private final SecureRandom random = new SecureRandom();
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpServletResponse response = (HttpServletResponse) resp;
String cookie = cookieValue(request);
if (cookie == null) {
byte[] bytes = new byte[32];
random.nextBytes(bytes);
cookie = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
Cookie c = new Cookie(COOKIE, cookie);
c.setPath("/");
c.setHttpOnly(false); // the GWT client has to read it
c.setSecure(request.isSecure());
response.addCookie(c);
}
if (!SAFE.contains(request.getMethod())) {
String header = request.getHeader(HEADER);
if (header == null || !MessageDigest.isEqual(
header.getBytes(StandardCharsets.UTF_8), cookie.getBytes(StandardCharsets.UTF_8))) {
response.sendError(HttpServletResponse.SC_FORBIDDEN, "Missing or invalid CSRF token");
return;
}
}
chain.doFilter(req, resp);
}
// cookieValue(request) returns the value of the XSRF-TOKEN cookie or null
}
It is mapped in web.xml to /api/* and /upload/*.
The comparison uses MessageDigest.isEqual, which takes the same time whatever the position of the first differing byte; the token is a random 256-bit value encoded as URL-safe Base64, and the cookie is Secure whenever the request arrived over TLS.
The answers observed with curl are in the table of the worked example further down (Worked example: a bounded upload and a fixed-upstream proxy).
A server that is also reachable cross-origin through CORS must not echo arbitrary origins together with credentials, or the same-origin guarantee that the token relies on is gone; see What is CORS? for the origin validation rules.
JSON hijacking and JSONP
CVE-2007-2378 and the classic attack
CVE-2007-2378 ("JavaScript Hijacking"), published on 2007-04-30, is the National Vulnerability Database entry for the design weakness that GWT, like every toolkit of the time, exchanged JSON with no protection scheme against cross-site script inclusion; the NVD entry lists all versions of GWT of that date and no fixed version, because it describes a practice and not a bug.
The attack needs three conditions: the server answers an authenticated GET (or a POST that a script tag can imitate) with a top-level JSON array or a JavaScript value that executes when evaluated; the answer depends on the user’s cookies; and the victim’s browser evaluates the response as a script.
The attacker’s page includes <script src="https://bookshelf.example/api/loans">, the browser sends the victim’s cookies, evaluates the response as JavaScript, and, in the engines of the time, the attacker could observe the data by redefining the Array constructor or the property setters before the include.
The same-origin policy does not intervene, because script inclusion across origins has always been allowed.
The Developer Guide article "Security for GWT applications" describes the attack and the countermeasures in its "JSON and XSRF" and "JSON and GWT" sections.
I did not test the constructor-redefinition trick, and the article itself is of 2007 vintage; current engines have closed most of it, but the cheap countermeasures below are still correct and cost nothing.
What the scenario shows, measured on the running server:
-
A GWT-RPC answer begins with
//OKor//EX, so evaluated as a script it is a comment and yields nothing, and the servlet sendsContent-Type: application/json;charset=utf-8together withContent-Disposition: attachment(theRPCServletUtils.writeResponsemethod sets both). GWT-RPC responses are therefore not hijackable by script inclusion, which is a design property worth keeping if you replace the transport. -
The Bookshelf’s hand-written JSON endpoint answers
GET /api/bookswith{"total":25,"books":[...]}, a top-level object, which is a syntax error as a script statement, and withX-Content-Type-Options: nosniff, which stops browsers from guessing a script type for it.
Hardening a JSON endpoint
-
Return a top-level object, never a bare array, so the response cannot be evaluated as an array literal.
-
Send
Content-Type: application/jsonandX-Content-Type-Options: nosniffon every JSON answer. -
Do not accept the session cookie alone for private reads: require a custom header or a token (the CORS preflight then blocks a cross-site script tag or form from sending it), or put private data behind
POST. -
The article’s own advice is to wrap JSON in a block comment that the client strips (
/*...*/) when the data is only fetched withXMLHttpRequest. It works forGETdata, costs one line on each side, and a client that usesJsonUtils.safeEvalorJSONParser.parseStrictneeds the stripping step first. -
Parse with the strict parser. In 2.13.1
JsonUtils.safeEvalcallsJSON.parse, andJSONParser.parseStrictusesJSON.parsewhere the browser has it.JSONParser.parseandparseLenientare deprecated and calleval()without validating the text (the Javadoc says so), which is the code-injection surface the article warns about. UseparseStrictorsafeEvalfor any string you do not fully control.
JSONP
JSONP is the practice of working around the same-origin policy by inserting a script tag whose response is a call of a function that the page defines.
GWT ships it as JsonpRequestBuilder (module com.google.gwt.jsonp.Jsonp, package com.google.gwt.jsonp.client), with requestObject, requestString and similar methods, and the Javadoc marks the URL parameter of send with @IsTrustedResourceUri.
That annotation is the warning: JSONP runs the answer as code in your page, with all of your page’s privileges.
Whoever controls the server or the URL controls your application, so a JSONP endpoint is a full cross-site scripting hole by design, and the article states that attacks against JSON and JSONP are fundamental, that "there’s nothing you can do to stop it" once the browser runs the code, and that the defence is not to send private data in that form.
Rules that follow:
-
Use JSONP only for public data from a service you trust completely, never for anything that depends on the user’s cookies.
-
Do not build the URL from user input.
-
Prefer CORS with
fetch/RequestBuilderfor cross-origin reads; see the CORS page and the Fetch API usage on the DOM & Elemental2 page. -
If you offer a JSONP endpoint yourself, validate the callback name against a strict identifier pattern, and do not offer it for authenticated data.
GWT-RPC deserialisation risks and hardening
The server decodes whatever bytes a client posts. What protects it is the serialisation policy, and what can go wrong is documented below. The mechanics of the policy file, the wire format and the exceptions are on the GWT-RPC page; here are the security properties.
The policy file is a whitelist
At compile time the GWT compiler computes, for each RemoteService, the set of types that may be sent and received, and writes it to <hash>.gwt.rpc next to the JavaScript (the Bookshelf’s has lines such as com.example.bookshelf.shared.Book, true, true, true, true, ...).
At run time RemoteServiceServlet loads the policy that the request names (the strong name is the second token of the payload) and StandardSerializationPolicy.validateDeserialize refuses any class that is not on the list:
Type 'com.example.SomeType' was not included in the set of types which can be deserialized by this SerializationPolicy or its Class object could not be loaded. For security purposes, this type will not be deserialized.
(The text is the message in StandardSerializationPolicy of the 2.13.1 source.)
So a hostile client cannot make the server instantiate an arbitrary class by naming it in the payload: only types that the compiler found reachable from the service interface can be created, and a class that is on the class path of the server but not in the policy is refused.
Two related checks are in the engine: a payload that names a service interface the servlet does not implement is refused with IncompatibleRemoteServiceException: Blocked attempt to access interface '...', which is not implemented by '...'; this is either misconfiguration or a hack attempt, and a value whose actual type does not fit the declared parameter type raises SerializedTypeViolationException (Attempt to deserialize an object of type X when an object of type Y is expected).
Consequences for hardening:
-
Deploy the policy file that belongs to the client you serve, and no other. A missing file makes the servlet fall back to a legacy 1.3.3-compatible policy, which is looser and logs a warning (
... a legacy, 1.3.3 compatible, serialization policy will be used); treat that warning as an error in production and see the deployment page for the causes (a stripped prefix, a CDN, a bean packaged intoBOOT-INF). -
Old policy files from earlier builds stay valid for old clients, which is intended, but a policy that allows a type you have since made sensitive keeps allowing it. Clean the deploy directory when a service’s type set shrinks (the Bookshelf’s
targetdirectory still held a policy for a long-deleted example service, which was the evidence). -
The policy limits types, not values. A valid
Bookwith a 100 MB title, a negative page size or an id that belongs to someone else is a perfectly legal payload. Validate sizes and ranges, and authorise access to the object (see Authentication and authorisation on the server). -
Cap the payload. The servlet reads the whole body into memory; set a request size limit in the container or in a filter in front of the RPC URL. The GWT-RPC page measures the payloads of the Bookshelf.
Enhanced classes (JPA and JDO) and the 2.10.1 and 2.11 change
Until GWT 2.10.0 the policy could include "enhanced classes", persistence-framework classes that the container adds server-only fields to.
To keep those fields across a client round trip, the server serialised them with Java object serialisation, put the bytes into the RPC payload as a Base64 string, and on the way back called ObjectInputStream.readObject on whatever the client had returned (visible in ServerSerializationStreamReader, in the branch that handles getClientFieldNamesForEnhancedClass).
There was no signature, so a client could substitute any serialised object graph, which is the textbook insecure-deserialisation vulnerability, and it was reported as GWT issue 9709, "Java Deserialization vulnerability in GWT-RPC".
The fix, in the release notes of 2.10.1 (backported) and 2.11.0, is to disable the enhanced-classes mechanism by default and log a warning when it is in use.
In 2.13.1 the switch is the system property gwt.enhancedClasses.enabled, read once by SerializationPolicyLoader and effective only when its value is exactly true:
-Dgwt.enhancedClasses.enabled=true
Do not set it.
The reliable fix, independent of any version, is the one every other section of the web uses for JPA entities: a service interface exposes DTOs, plain shared classes that carry the fields the client needs, and the server maps entities to DTOs and back (the Bookshelf’s Book and BookPage are such classes).
That also keeps lazy associations, internal ids, password hashes and audit columns out of the response, which is a leak, and not only a deserialisation, risk.
The GWT-RPC page of this section covers the DTO shapes and the compiler messages in detail.
Other points
-
Exceptions as an information leak. The default behaviour hands the exception message of a checked exception to the browser. Do not put SQL, file names or internal ids into messages of exceptions that cross the wire; log the detail on the server and throw a neutral exception.
-
UnexpectedExceptionand 500. An undeclared exception turns into a generic500with the textThe call failed on the server; see server log for details, which is the right behaviour: the details stay in the log. -
RPC protocol version 7 is evaluated by the browser. The client reads the answer with an
evalcall, so a Content Security Policy needsunsafe-eval, or the server must run with-Dgwt.rpc.version=8(see the GWT-RPC page, which measured both and quotes the browser’s refusal). Version 8 is the way to keepunsafe-evalout of the policy. -
GWT-RPC is not an API for other clients. The Developer Guide says so, and the payload format changes with every compile. An endpoint that other systems call is a REST or gRPC endpoint with its own authentication and its own versioning.
-
Do not trust the permutation header.
X-GWT-Permutationis checked for presence only (checkPermutationStrongName), as a weak anti-CSRF measure; any script can send it.
Authentication and authorisation on the server
The compiled client is a file that anyone can read, reformat and call.
Every rule that matters must therefore be enforced where the code is not visible to the user.
A conditional such as if (user.isAdmin()) showDeleteButton() is a usability feature; the servlet method behind the button must check the role again.
The checks have three layers.
-
Who is calling. Authenticate at the container or framework level, in front of the servlet: a
<security-constraint>inweb.xml, or a Spring Security filter chain, or an OpenID Connect gateway. Never accept an identity that the client states in the payload, such as a user id argument; take the caller fromgetThreadLocalRequest().getUserPrincipal()or the framework’s security context. -
What the caller may do. Authorise inside each service method, or through the framework:
request.isUserInRole("librarian"),@PreAuthorizeon a bean that the servlet delegates to, or a check inonAfterRequestDeserialized, which runs after the payload was decoded and before the method (Javadoc: a good place for audit logging and per-method authorisation). -
Which object. Role checks are not enough when ids are in the request: a member who may read their loans must not be able to read loan 7 of someone else by changing an argument. Look the object up through the caller’s identity, or compare its owner with the caller, on every call (the object-level check that OWASP lists as broken object level authorisation).
A container-protected area, measured
The lab declared the librarian area in web.xml and ran Jetty with a one-user realm (alice: changeit-placeholder,librarian, a placeholder password in a throw-away file; production uses a real user store and TLS):
<security-constraint>
<web-resource-collection>
<web-resource-name>Librarian area</web-resource-name>
<url-pattern>/upload/*</url-pattern>
<url-pattern>/proxy/*</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>librarian</role-name>
</auth-constraint>
</security-constraint>
<login-config>
<auth-method>BASIC</auth-method>
<realm-name>Bookshelf</realm-name>
</login-config>
<security-role>
<role-name>librarian</role-name>
</security-role>
An unauthenticated request got 401 Unauthorized with WWW-Authenticate: Basic realm="Bookshelf" before any filter or servlet of the application ran.
Basic authentication is shown only because it needs no extra page; use form login, OIDC or your framework’s mechanism with TLS in a real application.
The rest of the checks stay in the servlet, as the example in the next section shows with a belt-and-braces role check.
An order detail that matters for the filters on this page: the container’s <security-constraint> is evaluated before the servlet filters.
An unauthenticated POST therefore got the 401, not the 403 of the CSRF filter, and the CSRF filter ran only for authenticated requests; with Spring Security the order is explicit in the filter chain (CsrfFilter before the authorisation filter).
Rules that save projects
-
Check on the server, on every call, even for methods that "only the admin screen calls".
-
Fail closed: a missing principal or a missing role is a refusal.
-
Do not put secrets in client code or in the module: API keys, signing keys and database credentials stay on the server. The client, the
.nocache.jsfiles and the source maps are public. Examples and tests use placeholders and environment-based configuration, and a password never travels in an RPC payload without TLS and a server-side check. -
Keep the session short, mark session cookies
HttpOnly,SecureandSameSite, and renew the id at login (and then the XSRF token, see Renewing a token). -
Return the same message for "no such user" and "wrong password", rate-limit logins, and log authentication events.
-
Validate every argument on the server: the Bean Validation constraints of the shared classes also run in the browser, but only the server’s run is binding (see the Validation page).
Worked example: a bounded upload and a fixed-upstream proxy
Older tutorials, including the book this section draws on, contain upload and proxy servlets that are unsafe by today’s standards; this page shows only the bounded form. The two servlets below are that form, run on the same Jetty with the filter and the realm of the previous sections.
The upload servlet
The rules it enforces are: the caller is authenticated and in the librarian role; the CSRF filter has already checked the header; the size is capped twice (by <multipart-config> in web.xml, enforced by the container before the application code reads the data, and by an explicit check); the type is decided from the content (the first bytes), not from the declared content type or the file name; and the file name is chosen by the server (a random UUID plus a fixed extension), so the client’s name is never used in a path.
public class CoverUploadServlet extends HttpServlet {
private static final long MAX_BYTES = 1024 * 1024; // also enforced by <multipart-config>
private static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G'};
private static final byte[] JPEG = {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF};
private Path root;
@Override
public void init() {
root = Path.of(System.getProperty("java.io.tmpdir"), "bookshelf-covers")
.toAbsolutePath().normalize();
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws IOException, ServletException {
if (req.getUserPrincipal() == null || !req.isUserInRole("librarian")) { // belt and braces
resp.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
Part part = req.getPart("cover");
if (part == null || part.getSize() <= 0 || part.getSize() > MAX_BYTES) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Cover missing or larger than 1 MiB");
return;
}
byte[] head = new byte[4];
try (InputStream in = part.getInputStream()) {
in.readNBytes(head, 0, head.length);
}
String ext = startsWith(head, PNG) ? ".png" : startsWith(head, JPEG) ? ".jpg" : null;
if (ext == null) {
resp.sendError(HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, "Only PNG and JPEG covers");
return;
}
// The client's file name (part.getSubmittedFileName()) is never used.
Path target = root.resolve(UUID.randomUUID() + ext).normalize();
if (!target.startsWith(root)) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST);
return;
}
Files.createDirectories(root);
try (InputStream in = part.getInputStream()) {
Files.copy(in, target);
}
resp.setStatus(HttpServletResponse.SC_CREATED);
resp.setContentType("application/json");
resp.getWriter().write("{\"cover\":\"" + target.getFileName() + "\"}");
}
// startsWith(byte[], byte[]) compares a prefix
}
with this declaration, whose limits are in bytes:
<servlet>
<servlet-name>coverUpload</servlet-name>
<servlet-class>com.example.bookshelf.server.CoverUploadServlet</servlet-class>
<multipart-config>
<max-file-size>1048576</max-file-size>
<max-request-size>1100000</max-request-size>
</multipart-config>
</servlet>
<servlet-mapping>
<servlet-name>coverUpload</servlet-name>
<url-pattern>/upload/cover</url-pattern>
</servlet-mapping>
What a real deployment adds: antivirus or image re-encoding (decoding the image and writing it again strips payloads hidden in metadata and proves it is an image), storage outside the web root and outside the application’s code path, a quota per user, an Content-Disposition: attachment and nosniff when serving the file back, and the OWASP file-upload checklist (linked in the references).
Magic bytes prove the first four bytes, not the whole file; a servlet that must trust nothing re-encodes.
Source: OWASP — File Upload Cheat Sheet
The proxy servlet
A page that needs data from another host should call a servlet of its own server, and that servlet must never turn into a way to reach internal hosts. The safe shape is a fixed upstream with parameters that are parsed, clamped and re-encoded, never a URL taken from the request:
public class CatalogProxyServlet extends HttpServlet {
private static final String UPSTREAM = System.getProperty("bookshelf.upstream", "http://localhost:8942/api/books");
private static final int MAX_BYTES = 64 * 1024;
private final HttpClient http = HttpClient.newBuilder().connectTimeout(Duration.ofSeconds(2)).build();
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
int start = clamp(req.getParameter("start"), 0, 10_000, 0);
int length = clamp(req.getParameter("length"), 1, 100, 10);
URI uri = URI.create(UPSTREAM + "?start=" + start + "&length=" + length);
try {
HttpResponse<byte[]> up = http.send(
HttpRequest.newBuilder(uri).timeout(Duration.ofSeconds(3)).GET().build(),
HttpResponse.BodyHandlers.ofByteArray());
if (up.statusCode() != 200 || up.body().length > MAX_BYTES) {
resp.sendError(HttpServletResponse.SC_BAD_GATEWAY);
return;
}
resp.setContentType("application/json");
resp.setHeader("X-Content-Type-Options", "nosniff");
resp.getOutputStream().write(up.body());
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
resp.sendError(HttpServletResponse.SC_GATEWAY_TIMEOUT);
}
}
// clamp(String, min, max, default) parses an int and limits it to the range
}
The properties that make it safe: one upstream host written in the code or in configuration; only two integer parameters, clamped; connect and request timeouts; a response size limit; the caller is authenticated by the container; errors are mapped to status codes and logged, not swallowed.
If the real design needs several upstreams, use a server-side allow-list of names mapped to fixed base URLs (?source=openlibrary), never a host from the request, and resolve the host names yourself and reject loopback, link-local and private addresses (the OWASP server-side request forgery cheat sheet in the references).
Measured results
All the requests used curl; the cookie jar kept the XSRF-TOKEN cookie, the user was alice with the placeholder password, and <uuid> stands for the random name.
| Request | Status | Notes |
|---|---|---|
|
200 |
|
|
401 |
|
|
403 |
|
|
403 |
The same message. |
|
201 |
|
The same, with the text |
415 |
Rejected by the content check, not by the declared type. |
The same, with a file of 1,200,004 bytes |
400 |
|
|
200 |
The upstream JSON. |
|
200 |
The parameters were clamped to 0 and 100, so the upstream saw |
|
200 |
The |
The two classes, the filter and the web.xml fragments were compiled with javac --release 17 against jakarta.servlet-api 6.0.0 and run on Jetty 12.1.10 with JDK 21.
They are an illustration of the pattern, with no persistence, no quota and a single hard-coded role; they are not meant to be copied into production unchanged.
The upload servlet of the deployment page (Server Integration & Deployment) shows the <multipart-config> plumbing for a Spring Boot jar; combine it with the checks above.
The Super Dev Mode code server must never be exposed
The code server is a development tool.
It compiles your sources on demand, serves the result and the sources to the browser, and has no authentication at all.
It starts on port 9876 and binds to 127.0.0.1 unless told otherwise; the -bindAddress flag of CodeServer is documented in its own help as "Specifies the bind address for the code server and web server (defaults to 127.0.0.1)".
That default is the security boundary: only processes on the developer’s own machine can talk to it.
What the code server answered on the loopback address (port 9942 in the lab; the default is 9876), from the handlers in WebServer.java and from curl:
| Path | Answer observed | What it means |
|---|---|---|
|
200 HTML page |
Lists the modules and offers bookmarklets. |
|
200 JavaScript, about 19 KB |
The script that turns on development mode in a page. |
|
200 JavaScript |
The development-mode bootstrap. Requesting it makes the server compile the module. |
|
200 |
Triggers a compilation with a CPU and disk cost, from any client that can reach the port. |
|
200 |
Deletes the compilation caches. |
|
200 |
Build state and log of the module. |
|
200 with a directory listing of every package of your client code and of GWT’s own sources |
Source disclosure. |
|
200 and the Java source ( |
The source of your client code, including comments. |
|
200 HTML |
Lists the RPC policy files of the module. |
So anyone who can open the port can read your client sources, make the machine compile, and delete its caches.
The Jetty it embeds is the old 9.4 line (the log of the lab prints Powered by Jetty:// 9.4.58), which is another reason not to leave it listening.
What was run with a non-default flag: the code server was started once with -bindAddress 0.0.0.0 on a developer machine, its start-up line changed from ready at http://127.0.0.1:9942/ to ready at http://<lan-address>:9942/, and a request for the module’s nocache.js through the machine’s LAN address was answered with 200; that run was stopped immediately and every other endpoint check above was done on the loopback address.
That is the lesson itself: the port was reachable from the network the moment the flag changed, and the code server’s help says nothing about authentication because there is none.
Rules:
-
Never set
-bindAddressto anything but loopback on a shared network, a VPN, a cloud VM, a CI runner or a container with a published port. Remote debugging is better done with an SSH tunnel to127.0.0.1. -
Do not publish port 9876 (or the
-portyou chose) from a Docker container (-p 9876:9876binds0.0.0.0on the host unless you give an address) or a Kubernetes service. -
Do not start the code server in a production profile, and keep
gwt:codeserver,gwt-devandgwt-codeserverout of the production class path: they are build-time andprovideddependencies. -
The development-mode bootstrap
dev_mode_on.jsand the?superdevmodemechanism change the page’s scripts to ones loaded from the code server; only turn them on in a browser you use for development. -
The Maven plugin’s
codeserverArgsconfiguration lives in the project root POM for the multi-module layout of the Bookshelf (as the Development Mode & Debugging page explains), so check there for a bind address that someone added.
Source maps, symbol maps and what a production build leaves behind
Source maps map the compiled JavaScript back to Java. GWT 2.12.0 enabled source maps by default in all browsers for Super Dev Mode (release notes) and added the option for source maps to include the contents of the source files, which is what the code server serves. A production compile is different, and that was measured in the Bookshelf:
-
The production
*.cache.jsfiles of the web application contained nosourceMappingURLcomment (the compiled code ends with a//# sourceURL=bookshelf-0.jsline, which only names the script for debuggers), and the war held no.mapfile. -
The symbol maps, which turn obfuscated names back into method names (they decode stack traces of a user’s error report), were written to
bookshelf-client/target/gwt/deploy/bookshelf/symbolMaps/<strong-name>.symbolMap, outside the web application: the war held none andWEB-INF/deploywas empty. The-deploydirectory defaults toWEB-INF/deployunder the war and "deployable but not servable" files go there, while-extrareceives files "not intended for deployment". -
The RPC policy files
*.gwt.rpcmust be served and are in the module directory; they list type names and field layout, which is public information once the client is public.
The guidance:
-
Keep
-saveSourceoff in production builds unless you deliberately publish sources, and never publish the-extradirectory. -
Keep the symbol maps on the server, next to your log tooling, to de-obfuscate reported stack traces; they must not be in the web root.
-
If a Maven build packages
WEB-INF/deployor theextradirectory into the war, check that the container does not serve it.WEB-INFis never served by a servlet container, but a static server in front of an unpacked war may be configured wrongly. -
Treat any deployment as if the client code and the module’s file names were public, because they are. Obfuscation (
-style OBFUSCATED, the default) reduces size and discourages casual reading; it is not a security boundary. -
Stack traces and server details do not belong in answers to browsers. Containers' default error pages print the exception message and cause chain; the upload example above showed Jetty’s page with a
CAUSED BYsection for a 400. Configure an<error-page>and turn off detailed error output in production.
Other server-side points worth a line
-
Clickjacking. Send
X-Frame-Options: DENYor the CSP directiveframe-ancestors 'none'on the host page unless the application is meant to be embedded. -
TLS everywhere. Mark the session cookie
Secure, sendStrict-Transport-Security, and redirect plain HTTP. Cookies and tokens sent over plain HTTP are readable on the network. -
The host page. A dynamic host page that embeds data (the dynamic host page article) must encode it for the context (HTML text, attribute, script) or, better, avoid embedding data and fetch it with an RPC call; the SafeHtml & XSS page covers the encoding functions.
-
Logging. The
RemoteLoggingServiceImplof GWT’s logging module accepts log records from the client; rate-limit it and never log client-supplied text unescaped into a system that interprets it. -
Dependencies. The server side is Java: keep the servlet container, Spring Security and the
gwt-servletjar current. NVD lists no CVE against GWT releases from 2.6 on (checked 2026-10-08), but the container and the libraries in the war are ordinary software with ordinary advisories. -
Published CVEs of GWT. CVE-2007-2378 above is the design issue about JSON. The cross-site scripting entries CVE-2012-4563, CVE-2012-5920 and CVE-2013-4204 and the other advisories are tabulated on the SafeHtml & XSS page, because they are about generated client code and the JUnit support files.
Where the older books and tutorials are wrong or outdated
The book that this section cross-checks against (see the references) predates GWT 2.3 and so has no XSRF token mechanism: it treats the same-origin policy as the security model of GWT-RPC, which it is not. Some of its examples also show patterns that are unsafe today (credentials supplied by client code, unbounded server-side helpers); they should not be reproduced, and the correct form of each is on this page: credentials checked on the server over TLS and never stored in client code (Authentication and authorisation on the server), fixed upstreams, server-chosen file names and limits (Worked example: a bounded upload and a fixed-upstream proxy), tokens against cross-site forgery (XSRF protection for GWT-RPC and XSRF protection for RequestBuilder, REST and forms) and a policy-based serialisation whitelist (GWT-RPC deserialisation risks and hardening). The book also describes the old development-mode browser plugin, which no longer exists; the code server of this page is its replacement.
Pitfalls
| Message or symptom | Cause | Fix |
|---|---|---|
|
A protected service was called without |
Fetch the token first, install it on every proxy of a protected service, and start the application’s calls from the token callback. |
|
The token does not match the current session cookie: a stale token after the session id changed (login, timeout, restart) or a token from another session. |
Renew it in an |
|
The call arrived without the session cookie named by |
Make the context parameter match the container’s cookie name; check |
|
The token servlet was called with no session. Its |
Create the session before the first token request (a |
|
On Java 17 and later, any serialised exception, including |
Start the container JVM with |
|
Neither an init nor a context parameter named |
Add the context parameter to |
|
The service interface has exactly one serialisable |
Use the right token class or name the class with |
A service is called without any token and works, though the team thinks it is protected |
The servlet extends |
Extend the protected base classes on both sides, and add a test that a call without a token fails. |
|
The payload names a class outside the service’s whitelist: an old client, a stale policy, or a forged request. |
Deploy the policy that matches the client. For a forged request, nothing: the refusal is the defence. |
|
A request without |
Do not override |
Warning |
The policy file was not found for the module base URL. |
Fix the deployment so that |
JPA entities in a service interface and |
An old workaround for lazy-loading entities; it re-enables Java deserialisation of client data (issue 9709). |
Remove the property and send DTOs. |
|
The |
Create the cookie with |
Spring Security answers |
The default masking handler ( |
Use a request handler that resolves the plain value for the header, as the reference page for single-page applications describes. |
|
The container enforced |
Show a friendly message in the client; do not raise the limit without a reason. |
The code server answers on a LAN address |
|
Remove the flag; the default is |
|
The |
Keep them outside the served tree (symbol maps stay on the server for stack-trace decoding). |
The browser shows |
RPC protocol 7 is evaluated. |
Start the server with |
A pre-release checklist
-
Every state-changing endpoint needs either an RPC token (GWT-RPC), a CSRF token in a header or field (REST, forms) or a non-cookie credential.
-
Every servlet that is meant to be protected extends the protected base class, and one automated test asserts that a call without a token is refused.
-
Authentication is enforced in front of the servlets; every method checks the role and the ownership of the object it touches.
-
The serialisation policy is deployed and the warning about the legacy policy never appears;
gwt.enhancedClasses.enabledis not set; DTOs, not entities, cross the wire. -
JSON answers have a top-level object,
Content-Type: application/jsonandnosniff; no JSONP for private data; strict parsing in the client. -
Uploads and proxies are authenticated, size-limited, type-checked from content, and use server-chosen names and fixed upstreams.
-
The code server is bound to loopback and is not part of any deployed artifact; no source map, symbol map or extra output is served.
-
Cookies are
HttpOnly(the session),SecureandSameSite; TLS is on; error pages show no stack traces; the secrets live on the server only.
Recent changes
-
GWT 2.3 introduced the RPC token mechanism (
RpcToken,XsrfTokenService,XsrfProtectedService, the@XsrfProtectand@NoXsrfProtectannotations); the Developer Guide’s article "Security for GWT applications" still says in one passage that the team "is also considering" it, which dates the text. -
GWT 2.10.1 and 2.11.0 disabled JPA/JDO enhanced-class serialisation by default and added a warning (GWT issue 9709).
-
GWT 2.11.0 added the Jakarta variants of the servlet classes in
com.google.gwt.user.server.rpc.jakarta, includingXsrfProtectedServiceServlet,XsrfTokenServiceServletandAbstractXsrfProtectedServiceServlet. -
GWT 2.12.0 fixed CSP problems in the linkers and the development mode, provided CSP workarounds for GWT-RPC payloads (the
gwt.rpc.versionproperty; see Linkers & Bootstrap for the linkers) and enabled source maps by default in all browsers. -
GWT 2.13.0 removed the embedded Jetty launcher from development mode, so the code server is the only development server.
-
NVD lists no CVE against GWT 2.6 or later (checked 2026-10-08). CVE-2007-2378 is a design note about JSON; it has no fixed version.