Forms, Validation & Sessions
|
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 Jakarta Servlet 6.1, Jakarta Pages 4.0, Jakarta Expression Language 6.0 and Jakarta Standard Tag
Library 3.0 on Apache Tomcat 11.0.26 and Java 21, together with Jakarta Validation 3.1.1 and Hibernate Validator 9.1.4.Final.
Everything was verified on 2026-10-08: the facts against the specifications, the Javadoc and the Tomcat 11.0 and Hibernate
Validator documentation, and every example by building it into a Maven WAR, deploying it on Tomcat 11.0.26 and driving it with
curl and a cookie jar; the outputs shown are the outputs that run produced. A form is where a web application stops being a
set of pages and starts being a program: the visitor sends data, the server must decide whether to believe it, remember
something about the visitor, and answer in a way that survives a refresh. This page follows one form, the Bookshelf "add a
book" form, from the first GET to the saved record, and then widens to the state that surrounds forms: the session, the cart,
the cookie, the login. The servlet side of every topic (the HttpSession API, the cookie API, Part) is explained on
Servlet Fundamentals; this page is about using those APIs to build forms that
are correct, secure and pleasant to use.
A Form in One Picture
A form is not one request but a conversation of at least two: a GET that shows the form and a POST that submits it. A
correct implementation decides, on every submission, one of three outcomes: the data is unacceptable and the form is shown
again with the visitor’s input and the reasons; the data is acceptable and the application changes state and redirects; or the
request must not be processed at all (a replayed or forged submission). The rest of the page fills in each box of this diagram.
| Status | Meaning here | Notes |
|---|---|---|
200 |
The form (first |
A |
303 |
See Other: the answer to a successful |
Post/Redirect/Get; |
400 |
A field that cannot come from the form at all (unknown page size, quantity out of range) |
A tampered request, not a mistake the visitor can fix: no friendly redisplay |
409 |
Conflict: the token was already used or never issued |
Duplicate submission, an expired form, or a forged request |
413 |
Payload Too Large |
A multipart body above |
422 |
Unprocessable Content: the data is well-formed but not acceptable, the form is shown again |
|
The Bookshelf Used on This Page
The examples on this page are one Maven WAR, g7forms, with the same pom.xml shape as the Getting Started page of this
section (Jakarta Servlet, Pages and EL APIs with scope provided, the Jakarta Tags API and its GlassFish implementation, Java
21) plus one dependency: Hibernate Validator, which brings the Jakarta Validation API with it.
<dependency>
<groupId>org.hibernate.validator</groupId>
<artifactId>hibernate-validator</artifactId>
<version>9.1.4.Final</version>
</dependency>
Hibernate Validator needs a Jakarta Expression Language implementation to interpolate the ${...} expressions in constraint
messages. Tomcat provides one (the same implementation that evaluates the EL in the JSPs), and the application above ran with
no extra EL dependency on Tomcat 11.0.26. On a container that does not provide EL (a plain Java SE program, or a servlet
container without it) the Hibernate Validator documentation says to add an implementation such as org.glassfish.expressly:expressly.
| Path | Role |
|---|---|
|
The form-backing JavaBean, annotated with Bean Validation constraints |
|
Shows the form, binds, validates, saves, redirects; accepts an optional cover |
|
The list page; reads the |
|
A session-scoped cart |
|
The synchronizer token, the flash message and the filter that delivers it |
|
Builds the |
|
Application-managed login for |
|
|
|
Encodings, session cookie, the security constraint and form login, the realm |
The catalogue is the BookStore of the earlier pages (a CopyOnWriteArrayList of the Book record), started with three
titles and held in application scope; the page treats it as the model and does not repeat its code. The data-access side of
the Bookshelf (a repository in front of a database) belongs to the servlet
and MVC with Servlets & JSP pages of this section and to the Hibernate section; here a list is enough.
Reading What the Browser Sent
A browser submits a form as application/x-www-form-urlencoded (the default) or multipart/form-data (with
enctype="multipart/form-data", required for files). Either way the container decodes the body into request parameters,
which are always strings, and which a GET form puts in the query string and a POST form puts in the body. The Servlet API
has four ways to read them, and the Expression Language has two shortcuts that mirror them.
| API | Returns | Notes |
|---|---|---|
|
The first value, or |
An empty text field is present with the empty string, not absent |
|
A |
The way to read checkbox groups and |
|
An unmodifiable |
The base of every binding library |
|
An |
Useful for rejecting unknown fields |
|
The first value, |
|
|
The |
|
Three rules from the specification and the container shape every form servlet. The parameters of a POST are available only
after the container has read the body, so a filter that calls getReader() or getInputStream() first makes
getParameter return null (see Servlet Fundamentals). The decoding
uses the request character encoding, which defaults to ISO-8859-1 unless the application sets it: declare
<request-character-encoding>UTF-8</request-character-encoding> in web.xml, as the application on this page does, or
é arrives as é (the details are on Character Encoding). And since Servlet
6.1 the parameter methods are documented to throw a runtime exception when the body cannot be parsed (for a multipart request
over its limits, see File Upload in a Form).
The rest of the page reads parameters exactly as above. The next sections add what the API does not do for you: giving each control a value on redisplay, converting strings to types, and deciding what is acceptable.
Showing the Form and Keeping the Visitor’s Input
The GET handler’s only job is to put the model the view needs in request attributes and forward to a JSP under WEB-INF
(a JSP the browser can request directly would run without the servlet’s work). The same method serves the first display and
every redisplay after a failed submission, which is what makes fields sticky: the view always renders from a BookForm
object, an empty one the first time, the visitor’s own input afterwards.
@WebServlet("/forms/book")
@MultipartConfig(maxFileSize = 64 * 1024, maxRequestSize = 128 * 1024)
public class BookFormServlet extends HttpServlet {
static final List<String> FORMATS = List.of("paperback", "ebook", "audio");
static final List<String> TAGS = List.of("classic", "science-fiction", "essay", "poetry", "history");
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
show(req, resp, new BookForm(), Map.of());
}
private void show(HttpServletRequest req, HttpServletResponse resp, BookForm form, Map<String, String> errors)
throws ServletException, IOException {
req.setAttribute("form", form);
req.setAttribute("errors", errors);
req.setAttribute("formats", FORMATS);
req.setAttribute("allTags", TAGS);
req.setAttribute("formToken", FormToken.issue(req.getSession()));
req.getRequestDispatcher("/WEB-INF/views/book-form.jsp").forward(req, resp);
}
// doPost is shown in the sections that follow
}
The JSP writes each stored value back into the control, and every value is output-escaped for the place it lands: inside an
attribute value, c:out (or fn:escapeXml) turns ", <, >, & and ' into entities, which is the difference between a
form that redisplays "><script> as text and a form that executes it.
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<form method="post" action="${pageContext.request.contextPath}/forms/book" enctype="multipart/form-data">
<input type="hidden" name="formToken" value="<c:out value='${formToken}'/>">
<p>
<label for="title">Title</label>
<input id="title" name="title" value="<c:out value='${form.title}'/>" required maxlength="80"
aria-invalid="${not empty errors.title}" aria-describedby="title-error">
<span id="title-error"><c:out value="${errors.title}"/></span>
</p>
<fieldset>
<legend>Format</legend>
<c:forEach var="f" items="${formats}">
<label><input type="radio" name="format" value="${f}" ${form.format == f ? 'checked' : ''}> ${f}</label>
</c:forEach>
</fieldset>
<fieldset>
<legend>Tags</legend>
<c:forEach var="t" items="${allTags}">
<label><input type="checkbox" name="tags" value="${t}" ${form.tags.contains(t) ? 'checked' : ''}> ${t}</label>
</c:forEach>
<span id="tags-error"><c:out value="${errors.tags}"/></span>
</fieldset>
<p>
<label><input type="checkbox" name="newsletter" value="yes" ${form.newsletter ? 'checked' : ''}> Send me the newsletter</label>
</p>
<!-- year, published, isbn and the cover input follow the same pattern -->
<p>
<button type="submit" name="action" value="draft">Save draft</button>
<button type="submit" name="action" value="publish">Publish</button>
</p>
</form>
Posting a hostile title shows what the escaping does. A request with the title "><script>alert(1)</script> and the author
O'Brien & Co got a 422 and this redisplay, in which the markup is inert text inside the attribute:
<input id="title" name="title" value=""><script>alert(1)</script>" required maxlength="80"
<input id="author" name="author" value="O'Brien & Co" required maxlength="60">
The idioms for the controls differ only in how "selected" is spelled.
| Control | Idiom | Notes |
|---|---|---|
|
|
Redisplay the text the visitor typed, not the converted value (see below) |
|
|
No |
|
|
Compare the stored value with each option’s value |
|
|
EL 3 method call on the list; keep the list non-null |
a single |
|
A boolean property |
|
|
|
|
Never refill it |
Leave |
|
Cannot be refilled |
Browsers forbid it; the visitor must pick the file again |
The value attribute of a radio, checkbox or option comes from a server-side allowlist (formats, allTags), so the
${f} there is data the application chose and not a request parameter; anything that came from the request goes through
c:out. The EL on this page is the Expression Language of the EL
basics page, and c:out, c:forEach and c:if are on JSTL Core.
Binding: From Strings to a Typed Bean
Everything in a form is a string; the model wants numbers, dates, booleans and lists. Binding is the step that converts one
to the other, and a plain servlet does it by hand. The rule that keeps the form usable is that a conversion failure is a
validation error, not an exception: the visitor typed abc in the year field, and the answer is the form with the message
"The year must be a whole number" and abc still in the box.
private BookForm bind(HttpServletRequest req, Map<String, String> errors) {
BookForm f = new BookForm(); // only the fields we name: no mass assignment
f.setTitle(trim(req.getParameter("title")));
f.setAuthor(trim(req.getParameter("author")));
f.setIsbn(blankToNull(req.getParameter("isbn"))); // "" would fail @Pattern; null means "not given"
f.setFormat(req.getParameter("format"));
f.setYearText(trim(req.getParameter("year")));
try {
f.setYear(f.getYearText().isEmpty() ? null : Integer.valueOf(f.getYearText()));
} catch (NumberFormatException e) {
errors.put("year", "The year must be a whole number.");
}
f.setPublishedText(trim(req.getParameter("published")));
try {
f.setPublished(f.getPublishedText().isEmpty() ? null : LocalDate.parse(f.getPublishedText()));
} catch (DateTimeParseException e) {
errors.put("published", "Use the format yyyy-mm-dd.");
}
String[] tags = req.getParameterValues("tags"); // null when no box is ticked
List<String> chosen = new ArrayList<>();
if (tags != null) {
for (String t : tags) {
if (TAGS.contains(t)) { // an allowlist, not whatever the client sent
chosen.add(t);
}
}
}
f.setTags(chosen);
f.setNewsletter(req.getParameter("newsletter") != null); // an unticked box is absent, not "false"
return f;
}
private static String blankToNull(String s) {
return s == null || s.isBlank() ? null : s.strip();
}
private static String trim(String s) {
return s == null ? "" : s.strip();
}
The details that this method encodes are the usual sources of form bugs.
| Rule | Reason |
|---|---|
Keep the raw text ( |
The converted value is |
An unticked checkbox sends nothing |
|
A checkbox group sends the same name repeatedly |
Read it with |
Filter multi-values and radio values through an allowlist |
A client can send |
Trim, and map blank to |
An empty text field arrives as |
Read named fields only |
Copying every parameter into a bean is mass assignment: a client adds |
Convert dates with |
|
The hidden _newsletter marker is the convention to know when a form can be submitted partially: Spring’s checkbox support
sends a hidden field named with a leading underscore so that "absent because unticked" can be told from "absent because the
field was not on the form"; with a whole-form submission, as here, presence alone suffices.
param.tags=${fn:escapeXml(param.tags)}
paramValues.tags[1]=${fn:escapeXml(paramValues.tags[1])}
count=${fn:length(paramValues.tags)}
none=[${empty paramValues.tags}]
For ?tags=a&tags=b&tags=c the page printed param.tags=a, paramValues.tags[1]=b, count=3 and none=[false]; with no tags
parameter it printed an empty param.tags, count=0 and none=[true], so ${empty paramValues.tags} is the EL test for
"nothing ticked".
Dates: parsing one field and assembling three
The type="date" input submits the ISO form yyyy-mm-dd, which LocalDate.parse reads. Older forms offered three selects
(day, month, year) and assembled the date on the server, where the failure is a calendar error that parsing three valid
numbers does not catch: February 30 has a valid day, a valid month and a valid year.
LocalDate.of(Integer.parseInt("2026"), Integer.parseInt("2"), Integer.parseInt("30"));
// java.time.DateTimeException: Invalid date 'FEBRUARY 30'
LocalDate.parse("2026-13-40");
// java.time.format.DateTimeParseException: Text '2026-13-40' could not be parsed:
// Invalid value for MonthOfYear (valid values 1 - 12): 13
In the view, fmt:parseDate from JSTL Formatting does the same job inside a page,
but a page that parses user input has put validation in the view; a servlet that binds, as above, keeps the view a view.
Why not jsp:setProperty property="*"
jsp:setProperty with property="*" is the JSP way to bind: it copies every request parameter whose name matches a bean
setter, converting strings with the rules described on
Standard Actions & JavaBeans. It is convenient, and it is
mass assignment. A page that binds the form bean this way:
<jsp:useBean id="f" class="org.example.bookshelf.BookForm"/>
<jsp:setProperty name="f" property="*"/>
title=${f.title} format=${f.format} newsletter=${f.newsletter} year=${f.year}
and a request ?title=X&format=hacked&newsletter=true&year=1999 printed title=X format=hacked newsletter=true year=1999: the
format value that the servlet’s allowlist would have rejected went straight into the bean, and with ?year=abc the page
answered 500 with a JasperException whose root cause was java.lang.NumberFormatException: For input string: "abc" (observed).
There is no place in that page to turn the failure into a message beside the field, which is why the servlet does the binding.
Validating on the Server
Validation answers one question: is this input acceptable to the application? It happens on the server, always, because anything the browser did can be skipped by a client that is not a browser. It has two layers that should not be confused: syntactic validity (is this a number, a date, a known format) and semantic validity (is a year of 1200 plausible for a book, is an ISBN required for publishing). Bean Validation covers the second layer and most of the first, declaratively, on the bean.
The OWASP Input Validation Cheat Sheet states the principle that shapes every constraint below: prefer an allowlist of what is
valid over a denylist of what is invalid. A @Pattern of paperback|ebook|audio is an allowlist; a check for <script> is
a denylist that will always miss something, and output escaping, not input rejection, is what stops cross-site scripting.
Bean Validation in a servlet
Jakarta Validation is the specification (annotations plus the Validator API); Hibernate Validator is its reference
implementation. The mechanism is the same inside a servlet as anywhere else, and the page on
Validation with Hibernate Validator covers the annotations and
the validator in depth: this page shows the part that is specific to the servlet container, which is where the Validator
lives and how violations get back to the view.
public class BookForm {
/** Validation group: only checked when the visitor presses "Publish". */
public interface Publish {
}
@NotBlank(message = "{book.title.required}")
@Size(max = 80, message = "{book.title.size}")
private String title = "";
@NotBlank
@Size(max = 60)
private String author = "";
@NotNull(message = "{book.year.required}")
@Min(value = 1450, message = "{book.year.min}")
@Max(value = 2100, message = "{book.year.max}")
private Integer year;
@NotNull
@PastOrPresent
private LocalDate published;
@Pattern(regexp = "paperback|ebook|audio", message = "{book.format.invalid}")
private String format = "paperback";
@Size(max = 3, message = "{book.tags.size}")
private List<String> tags = new ArrayList<>();
@NotBlank(groups = Publish.class, message = "{book.isbn.required}")
@Pattern(regexp = "[0-9-]{10,17}", message = "{book.isbn.invalid}")
private String isbn;
@AssertTrue(message = "{book.audio.noisbn}")
public boolean isAudioWithoutIsbn() {
return !"audio".equals(format) || isbn == null;
}
// getters and setters, newsletter, yearText, publishedText omitted
}
| Constraint | What it checks |
|---|---|
|
The value is not |
|
Not |
|
A string with at least one character that is not whitespace |
|
Length of a string, size of a collection, map or array |
|
Numeric bounds and digit counts |
|
A string matches the regular expression in full; |
|
A loose syntactic e-mail check; it is not a proof that the address exists |
|
Dates and times relative to now |
|
A boolean property or method; the hook for cross-field rules |
Where the Validator lives
Building a ValidatorFactory is expensive and the Validator it hands out is thread-safe, so a web application builds them
once when it starts and shares the Validator through the ServletContext. A ServletContextListener is the natural home,
and it is also where the factory is closed.
@WebListener
public class AppListener implements ServletContextListener {
private ValidatorFactory factory;
@Override
public void contextInitialized(ServletContextEvent event) {
ServletContext ctx = event.getServletContext();
factory = Validation.byProvider(HibernateValidator.class).configure() // expensive: build once
.locales(Locale.ENGLISH, Locale.of("es"))
.defaultLocale(Locale.ENGLISH)
.localeResolver(context -> RequestLocale.get()) // messages in the visitor's language
.buildValidatorFactory();
ctx.setAttribute("validator", factory.getValidator()); // Validator is thread-safe
ctx.setAttribute("books", new BookStore());
ctx.setAttribute("users", UserStore.demo(ctx));
}
@Override
public void contextDestroyed(ServletContextEvent event) {
if (factory != null) {
factory.close();
}
}
}
With no Hibernate-specific configuration, Validation.buildDefaultValidatorFactory() is the one-line equivalent. The locales,
defaultLocale and localeResolver calls are the Hibernate Validator extension that makes a message follow the request: by
default the messages come from the JVM’s default locale, which on a server is not the visitor’s. The RequestLocale helper is
a ThreadLocal set by the FlashFilter below from request.getLocale() and cleared in a finally block (a pooled thread
must never keep the previous visitor’s locale). With the ValidationMessages_es.properties bundle holding
book.title.required=Escriba un título., a request with Accept-Language: es and an empty title redisplayed
"Escriba un título.", en-GB redisplayed "Enter a title." and fr, which is not in the list, fell back to English (observed).
From violations to messages beside the fields
validator.validate(form) returns a Set<ConstraintViolation<BookForm>>; each violation has a property path (title,
year, audioWithoutIsbn) and an interpolated message. The view wants a map from field to message, so the servlet folds the
set into one.
public static Map<String, String> byField(Set<? extends ConstraintViolation<?>> violations) {
Map<String, String> errors = new TreeMap<>();
for (ConstraintViolation<?> v : violations) {
errors.merge(v.getPropertyPath().toString(), v.getMessage(),
(a, b) -> a.compareTo(b) <= 0 ? a : b); // one message per field, deterministic
}
return errors;
}
// in BookFormServlet.doPost, after bind(req, errors) has collected the conversion errors:
Validator validator = (Validator) getServletContext().getAttribute("validator");
boolean publish = "publish".equals(req.getParameter("action"));
Set<ConstraintViolation<BookForm>> violations = publish
? validator.validate(form, Default.class, BookForm.Publish.class)
: validator.validate(form);
for (ConstraintViolation<BookForm> v : violations) {
errors.putIfAbsent(v.getPropertyPath().toString(), v.getMessage()); // keep the conversion error
}
The ordering of the violation set is unspecified, which is why byField sorts, and why the servlet uses putIfAbsent: when
a conversion error was already recorded for year (the text was not a number) the bean’s year is null, and @NotNull would
add a second, misleading "Enter the year" message for the same field. The first message wins.
A request that broke six rules at once (empty title, abc as the year, an invalid date, four tags, an ISBN 123, and format
audio with an ISBN) got a 422 and this summary, from the errors map, each message a link to its field (observed):
<div role="alert">
<p>Please correct 6 problem(s):</p>
<ul>
<li><a href="#year">year</a>: The year must be a whole number.</li>
<li><a href="#published">published</a>: Use the format yyyy-mm-dd.</li>
<li><a href="#title">title</a>: Enter a title.</li>
<li><a href="#audioWithoutIsbn">audioWithoutIsbn</a>: Audio books have no ISBN: leave the field empty.</li>
<li><a href="#isbn">isbn</a>: “123” is not an ISBN: use digits and hyphens, 10 to 17 characters.</li>
<li><a href="#tags">tags</a>: Pick at most 3 tags.</li>
</ul>
</div>
The messages are in a resource bundle (ValidationMessages.properties at the classpath root, the bundle name Hibernate
Validator reads), so wording is not in the code and a locale adds a file. The {min}, {max} and {value} placeholders in
the bundle are constraint attributes, filled in by the validator: book.title.size uses {max} of @Size, and book.year.min
uses {value}, the name of the attribute of @Min.
book.title.required=Enter a title.
book.title.size=The title may have at most {max} characters.
book.year.min=The year must be {value} or later.
book.year.max=The year must be {value} or earlier.
book.isbn.required=An ISBN is required to publish.
book.isbn.invalid=“${validatedValue}” is not an ISBN: use digits and hyphens, 10 to 17 characters.
book.audio.noisbn=Audio books have no ISBN: leave the field empty.
Two things in that bundle deserve a warning. First, ${validatedValue} is an Expression Language expression that the
validator evaluates, and it inserts what the visitor typed: the message for the ISBN <b>x</b> contained the raw markup,
which c:out turned into <b>x</b> on output (observed). The rule is the same as for every other value: a message is
data, and the view escapes it. Second, the reverse direction is the dangerous one: a custom constraint validator that builds a
message template by concatenating user input lets the visitor’s text be evaluated as an expression. The Hibernate Validator
documentation says Expression Language is disabled by default for custom violations for this reason, and that
addExpressionVariable() is the only safe way to put a value in a message.
Null versus empty, order and groups
Three behaviours account for most "my validation does not work" reports.
| Behaviour | What to do about it |
|---|---|
|
A text field the visitor leaves empty arrives as |
|
Use |
The violation set has no order |
Sort, or keep field order yourself, before showing a summary |
A bean-level rule has a bean-level path |
The |
The same bean, different rules for different buttons |
Validation groups: the "Publish" button validates |
Groups are why the form has two buttons. A draft needs a title, author, year and date; publishing also needs an ISBN. The same
BookForm serves both, and the servlet picks the groups from the submit button’s name="action" value. A publish request
without an ISBN got isbn-error: An ISBN is required to publish., and the identical request as a draft was saved with a 303
(observed). The same published date set in the future (2999-01-01) failed @PastOrPresent with
the default message "must be a date in the past or in the present", which shows what a message looks like when the
constraint has no message of its own.
What the view must show
Every error needs to reach the visitor in three places, and a form that skips one fails some of its users.
| Element | Purpose |
|---|---|
A summary with |
Screen readers announce it when the page loads; each item links to its field ( |
A message element next to each field, with an |
The field points to it with |
The visitor’s input, redisplayed |
Never make a visitor retype a long form because one field was wrong |
A |
Without it the control has no accessible name and the error has nothing to attach to |
A status that is not only colour |
A red border alone is invisible to colour-blind users |
Client-Side Validation: A Convenience, Never a Control
The browser can check a form before sending it. The HTML attributes required, maxlength, min, max, pattern,
type="number", type="email" and type="date" give instant feedback with no JavaScript, and a script can use the
Constraint Validation API for more. The form on this page uses them (required maxlength="80" on the title, min="1450"
max="2100" on the year), and the server checks everything again.
| Aspect | Client side (HTML attributes, JavaScript) | Server side (the servlet) |
|---|---|---|
Purpose |
Speed and comfort: feedback before the round trip |
Correctness and safety: the only check the application can rely on |
Can be bypassed |
Yes: |
No |
Knows the database |
No (without an extra request) |
Yes: uniqueness, existence, permissions |
Where messages come from |
Browser wording or your script |
The message bundle, in the visitor’s locale |
Duplicated rules |
Must mirror the server rules by hand |
The source of truth |
Every curl request in this page’s examples skipped the HTML attributes entirely, and the 422 for year=abc in a field
declared type="number" shows that the server cannot assume the browser enforced anything. An older generation of frameworks
generated JavaScript validators from server configuration; the books show them, and Struts 7.4 still has client-side
validation for the xhtml and css_xhtml themes but has deprecated it (WW-5694) (see the Struts validation page of this
section).
Post/Redirect/Get and Flash Messages
A successful POST that answers with a page leaves the browser’s current entry as a POST: refresh and the browser asks to
re-send the form, and re-sending creates a second book. The fix is Post/Redirect/Get (PRG): the POST handler changes state
and answers with a redirect to a GET URL, so a refresh repeats only the harmless GET. Servlet 6.1 lets the handler choose the
status and whether the buffer is cleared:
BookStore store = (BookStore) getServletContext().getAttribute("books");
Book saved = store.add(form.getTitle().strip(), form.getAuthor().strip(), form.getYear());
Flash.put(req.getSession(), "Saved “" + saved.title() + "”" + (cover == null ? "" : " with a cover"));
resp.sendRedirect(req.getContextPath() + "/forms/books", HttpServletResponse.SC_SEE_OTHER, true);
A redirect is a new request, so the request attributes are gone. The message "Saved" has to cross the redirect, and the one
place that lives across two requests is the session. A flash message is a value written to the session before the redirect and
removed by the first page view that reads it. The FlashFilter does the move from session to request, so every view reads it
the same way and no servlet has to remember to remove it:
public final class Flash {
public static final String ATTR = "flash";
public static void put(HttpSession session, String message) {
session.setAttribute(ATTR, message);
}
}
@WebFilter("/forms/*")
public class FlashFilter implements Filter {
@Override
public void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest request = (HttpServletRequest) req;
HttpSession session = request.getSession(false); // never create a session just to look
if (session != null && "GET".equals(request.getMethod())) { // only a page view may consume the message
Object message = session.getAttribute(Flash.ATTR);
if (message != null) {
session.removeAttribute(Flash.ATTR);
req.setAttribute(Flash.ATTR, message);
}
}
RequestLocale.set(req.getLocale());
try {
chain.doFilter(req, resp);
} finally {
RequestLocale.clear();
}
}
}
The page that shows it, books.jsp, prints the message with c:out when it exists:
<c:if test="${not empty flash}"><p role="status"><c:out value="${flash}"/></p></c:if>
The sequence below is the whole cycle as curl saw it, with a cookie jar so that the session carries across (output
trimmed to the lines that matter):
$ curl -s -i -b jar -c jar -F formToken=$TOKEN -F title=Hyperion -F author='Dan Simmons' -F year=1989 \
-F published=1989-05-26 -F format=ebook -F tags=science-fiction -F newsletter=yes -F action=draft \
-F cover=@cover.png\;type=image/png http://localhost:18080/g7forms/forms/book
HTTP/1.1 303
Location: /g7forms/forms/books
$ curl -s -b jar -c jar http://localhost:18080/g7forms/forms/books | grep -n 'role="status"\|Hyperion'
8:<p role="status">Saved “Hyperion” with a cover</p>
32: <li>Hyperion by Dan Simmons (1989)
$ curl -s -b jar -c jar http://localhost:18080/g7forms/forms/books | grep -c 'role="status"'
0
Source: curl — the -F (--form) option
Three details of flash messages decide whether they work.
| Rule | Why |
|---|---|
Consume the message in a page view, not in any request |
The first version of this filter moved the message on every request; a cart |
Never create a session just to look for a message |
|
Store a string or a small object, not a request or a bean from the database |
The session may be serialized or replicated; see the sessions section |
Escape it on output |
The message often contains data the visitor typed (the book title above) |
Two tabs share one session |
A message set in one tab can be shown in the other; for a message that must be bound to one navigation, carry an id in the redirect URL |
Frameworks package the same idea: Spring MVC has RedirectAttributes and flash attributes, and Struts 2 has the Message Store
interceptor for action messages and errors that must survive a redirect (the Struts Action Chaining, Sessions & Scopes page of this section covers sessions and scopes). The
mechanism underneath is always a short-lived value in the session.
Duplicate Submissions and Tokens
Post/Redirect/Get stops the refresh problem. It does not stop a visitor double-clicking the submit button (two POST`s are
on the wire before the first redirect arrives), a back-button resubmission, or a forged submission from another site that
rides the visitor’s cookie. One mechanism covers all three: a synchronizer token. The server generates a random value when it
renders the form, remembers it in the session and puts it in a hidden field; the `POST is accepted only if it carries a value the
server issued, and the token is removed when it is used.
public final class FormToken {
public static final String PARAM = "formToken";
public static final String CSRF_PARAM = "csrf";
private static final int MAX_OUTSTANDING = 10; // one per open tab or form, bounded
private static final SecureRandom RANDOM = new SecureRandom();
/** Issues a token for a form about to be rendered. */
public static String issue(HttpSession session) {
byte[] bytes = new byte[32];
RANDOM.nextBytes(bytes);
String token = Base64.getUrlEncoder().withoutPadding().encodeToString(bytes);
synchronized (session) {
Deque<String> d = tokens(session);
d.addLast(token);
while (d.size() > MAX_OUTSTANDING) {
d.removeFirst();
}
}
return token;
}
/** Consumes the submitted token: true exactly once per issued token. */
public static boolean consume(HttpSession session, String submitted) {
if (submitted == null) {
return false;
}
byte[] given = submitted.getBytes(StandardCharsets.UTF_8);
synchronized (session) {
Iterator<String> it = tokens(session).iterator();
while (it.hasNext()) {
if (MessageDigest.isEqual(it.next().getBytes(StandardCharsets.UTF_8), given)) {
it.remove();
return true;
}
}
}
return false;
}
// csrf(session) and checkCsrf(session, submitted): the same idea with one stable token per session
}
In doPost the check is the first thing that happens after the multipart limits:
if (!FormToken.consume(req.getSession(), req.getParameter(FormToken.PARAM))) {
resp.sendError(HttpServletResponse.SC_CONFLICT, "This form was already submitted or has expired.");
return;
}
The verification was a replay: the same valid form (same formToken) posted twice. The first POST was accepted with a 303, the
second answered 409 (observed); a POST with the token of a form that had already failed validation also answered 409, because
the 422 response renders a new form with a new token, and the old one is spent.
| Kind | Used by | Lifetime and behaviour |
|---|---|---|
Single-use ( |
Forms that create something: add a book, log in |
One token per rendered form; consumed on use; a bounded deque per session lets several tabs work at once |
Per-session ( |
Forms that can legitimately be submitted many times from one page: the "Add to cart" buttons, set page size, logout |
One random value per session; checked, never consumed |
A page that renders ten "Add to cart" forms with one single-use token would accept only the first button; that is why the list
page, the cart and the logout button carry the stable csrf value instead. Both are compared with MessageDigest.isEqual, a
constant-time comparison, and both come from SecureRandom; a token made from a counter, a timestamp or Math.random() can
be guessed. A missing or wrong token answered 409 on the book form and on the cart and 403 on logout (observed), and a token
that belongs to a different session is just as wrong as no token (a POST with a bogus JSESSIONID answered 409).
The token is also the application’s CSRF defence, and the SameSite cookie attribute (cookies section below) is a second layer
that does not replace it; the cookie mechanics are on Servlet
Fundamentals, and the Security page of this section covers CSRF, XSS and the other hardening of a JSP application. Struts 2
ships the same idea as the <s:token> tag (see Double-Submit Tokens & Wait Pages) with the token and tokenSession interceptors, and Spring Security adds a CSRF
token to every state-changing form by default; both are covered in their own sections.
File Upload in a Form
A form with a file input needs enctype="multipart/form-data" and a servlet that opted in to multipart with @MultipartConfig
(the annotation, Part API and storage rules are on Servlet
Fundamentals). What is specific to a form is that the text fields and the file arrive in one request, and that an upload
over the limits breaks the parameter parsing for the whole request, not just the file.
// 0. a multipart body over the @MultipartConfig limits fails here, before any parameter can be read
try {
req.getParts();
} catch (IllegalStateException e) {
resp.sendError(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE, "The upload is too large.");
return;
}
Without that block, the first req.getParameter(FormToken.PARAM) is the line that throws: with a 100 KB file and a 64 KB
maxFileSize, Tomcat 11.0.26 answered 413 with its own error report, and the stack trace in the report began
org.apache.tomcat.util.http.InvalidParameterException: ... FileSizeLimitExceededException: The field cover exceeds its maximum
permitted size of 65536 bytes thrown from Request.getParameter (observed). That class extends IllegalStateException, which
is what Servlet 6.1 permits for a parameter-parsing failure (issue 431 of the 6.1 change list). Reading the parts first turns a
container error page that shows a stack trace into an answer the application controls. Because the body was rejected, no
field is available (not the token, not the title), so the form cannot be redisplayed sticky; a 413 with a message is the
honest answer.
Within the limits, the cover is one more field to validate, with the same discipline as the rest: size is already limited by the annotation, and the content is checked rather than the client’s claims.
private static final byte[] PNG = {(byte) 0x89, 'P', 'N', 'G'};
private String checkCover(HttpServletRequest req, Map<String, String> errors) throws IOException, ServletException {
Part part = req.getPart("cover");
if (part == null || part.getSize() == 0) {
return null; // the file is optional
}
byte[] head = new byte[PNG.length];
try (InputStream in = part.getInputStream()) {
if (in.readNBytes(head, 0, head.length) != head.length || !java.util.Arrays.equals(head, PNG)) {
errors.put("cover", "The cover must be a PNG image.");
return null;
}
}
return part.getSubmittedFileName();
}
A text file named fake.png and declared image/png answered 422 with "The cover must be a PNG image" beside the cover input;
a real PNG was saved. The file name returned by getSubmittedFileName() is display text only; the stored name, when the cover is
written, must be chosen by the server (a random id), as on the Servlet Fundamentals page, and the file must be kept outside
the web root.
Sessions: What to Keep and What Not To
The session is server-side memory attached to one browser by the JSESSIONID cookie (the mechanism, timeouts and
configuration are on Servlet Fundamentals). A form-driven application
uses it for a short list of things, and the discipline is to keep that list short.
| Belongs in the session | Why | Notes |
|---|---|---|
The authenticated user’s identity (id, name, roles) |
It must survive requests and must not be client-controlled |
Rotate the id when it is set (see login) |
A cart of ids and counts |
It is per-visitor and short-lived |
Store ids, load prices and titles from the catalogue on each request |
Flash messages, form tokens |
They live for one or two requests |
Bounded and removed after use |
A multi-step form’s partial answers |
They are not yet committed |
Remove them when the wizard ends or is abandoned |
Does not: the whole |
Memory per visitor multiplies by every visitor; entities go stale |
Anything large or shared belongs in application scope or a cache, anything durable in a database |
Does not: a secret the browser should not have, such as another user’s data |
Session fixation or a leaked id exposes everything in it |
Keep sensitive data out unless it must be there |
Objects stored in a session follow the rules from Servlet Fundamentals: small, Serializable (the container may persist or
replicate the session), and thread-safe, because two requests of one visitor (two tabs, an image and a page) can run at the
same time. The cart holds ids and counts and synchronizes its own methods:
public class Cart implements Serializable {
private static final long serialVersionUID = 1L;
private final Map<Long, Integer> quantities = new LinkedHashMap<>();
public synchronized void add(long id, int quantity) {
quantities.merge(id, quantity, Integer::sum);
}
public synchronized void remove(long id) {
quantities.remove(id);
}
public synchronized Map<Long, Integer> getQuantities() {
return new LinkedHashMap<>(quantities); // a copy: the view never sees the live map
}
public synchronized int getCount() {
return quantities.values().stream().mapToInt(Integer::intValue).sum();
}
}
A session-scoped cart with validation and PRG
The cart servlet puts the previous sections together: a stable CSRF token, validation of every parameter (the book exists, the quantity is in range), a check-then-set on the session guarded by a lock, a flash message and a redirect.
@WebServlet("/forms/cart")
public class CartServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
BookStore store = (BookStore) getServletContext().getAttribute("books");
HttpSession session = req.getSession();
Cart cart = (Cart) session.getAttribute("cart");
Map<Book, Integer> lines = new LinkedHashMap<>();
if (cart != null) {
cart.getQuantities().forEach((id, qty) -> store.find(id).ifPresent(b -> lines.put(b, qty)));
}
req.setAttribute("lines", lines);
req.setAttribute("csrf", FormToken.csrf(session));
req.getRequestDispatcher("/WEB-INF/views/cart.jsp").forward(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
HttpSession session = req.getSession();
if (!FormToken.checkCsrf(session, req.getParameter(FormToken.CSRF_PARAM))) {
resp.sendError(HttpServletResponse.SC_CONFLICT);
return;
}
BookStore store = (BookStore) getServletContext().getAttribute("books");
long id;
int qty;
try {
id = Long.parseLong(req.getParameter("book"));
String q = req.getParameter("qty");
qty = q == null ? 1 : Integer.parseInt(q);
} catch (NumberFormatException e) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "book and qty must be numbers");
return;
}
if (store.find(id).isEmpty() || qty < 1 || qty > 10) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "unknown book or quantity out of range");
return;
}
synchronized (session) { // check-then-set on the session: guard it
Cart cart = (Cart) session.getAttribute("cart");
if (cart == null) {
cart = new Cart();
session.setAttribute("cart", cart);
}
if ("remove".equals(req.getParameter("action"))) {
cart.remove(id);
} else {
cart.add(id, qty);
}
}
Flash.put(session, "Cart updated.");
resp.sendRedirect(req.getContextPath() + "/forms/cart", HttpServletResponse.SC_SEE_OTHER, true);
}
}
The synchronized (session) block guards the check-then-set (read the attribute, create the cart if absent, store it): without
it two simultaneous first requests both see null and the second setAttribute discards the first cart. The specification
makes the application responsible for the thread safety of what it stores (section 7.7.1) but does not say that every request
receives the same HttpSession object, so a strictly portable application creates the cart once, at session creation in an
HttpSessionListener, and then relies only on the cart’s own synchronized methods; the lock above is the common pragmatic
form, and the cart’s methods are safe on any container.
Driving it with curl (cookie jar, the stable token read from the list page) gave this, in order: two adds returned 303; an
unknown book and a quantity of 99 returned 400; a missing token and a wrong token returned 409; the cart page then listed
"Dune x 2" and "Neuromancer x 1" with "Items in the session cart: 3"; a remove of Dune returned 303 and the page then showed
only Neuromancer and a count of 1 (observed).
<c:forEach var="line" items="${lines}">
<li><c:out value="${line.key.title}"/> x <c:out value="${line.value}"/>
<form method="post" action="${pageContext.request.contextPath}/forms/cart">
<input type="hidden" name="csrf" value="<c:out value='${csrf}'/>">
<input type="hidden" name="book" value="${line.key.id}">
<button name="action" value="remove">Remove</button>
</form></li>
</c:forEach>
<p>Items in the session cart: <c:out value="${sessionScope.cart.count}" default="0"/></p>
The sessionScope map and the scope search order are on
Implicit Objects & Scopes. line.key.title works because Book
is a record and EL 6.0 reads record components as properties (the EL Static Access, Records & Optional page notes that this is not true of
Struts' OGNL).
Pages that should not start a session
A JSP that is requested directly starts a session by default: the session attribute of the page directive defaults to
true, and the container then adds Set-Cookie: JSESSIONID. For a page that needs no session (a login form, a static help
page, a feed), session="false" removes both the cost and the cookie.
<%-- probe3.jsp: default --%>
<%@ page contentType="text/plain; charset=UTF-8" %>
default page: session created=${pageContext.session['new']}
<%-- probe4.jsp --%>
<%@ page contentType="text/plain; charset=UTF-8" session="false" %>
session=false page
The default page answered with Set-Cookie: JSESSIONID=...; Path=/g7forms; HttpOnly and printed session created=true; the
session="false" page answered with no Set-Cookie header. The expression is written session['new'] because new is a
reserved word in EL 6.0: ${pageContext.session.new} fails with ELException: Failed to parse the expression (observed).
The servlet that forwards to a JSP has usually created the session already (the form servlet does, to issue the token); the
directive matters for pages the browser requests directly. The directive is on Directives.
Cookies: Small, Visible and Not Trusted
A cookie is a name and value the browser stores and returns, and everything about it is under the visitor’s control. It is the right place for a preference (page size, theme, language) that is harmless if tampered with, and the wrong place for identity or authorization (that is the session’s job, and the session id is itself a cookie the container manages). The list page lets the visitor choose how many books to show and remembers it in a cookie.
@WebServlet("/forms/books")
public class BooksServlet extends HttpServlet {
private static final List<String> PAGE_SIZES = List.of("5", "10", "20");
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
BookStore store = (BookStore) getServletContext().getAttribute("books");
int pageSize = 10;
Cookie[] cookies = req.getCookies(); // null when the client sent none
if (cookies != null) {
for (Cookie c : cookies) {
if (c.getName().equals("pageSize") && PAGE_SIZES.contains(c.getValue())) { // never trust a cookie
pageSize = Integer.parseInt(c.getValue());
}
}
}
List<Book> all = store.all();
req.setAttribute("books", all.subList(0, Math.min(pageSize, all.size())));
req.setAttribute("pageSize", pageSize);
req.setAttribute("pageSizes", PAGE_SIZES);
req.setAttribute("csrf", FormToken.csrf(req.getSession()));
req.getRequestDispatcher("/WEB-INF/views/books.jsp").forward(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws IOException {
if (!FormToken.checkCsrf(req.getSession(), req.getParameter(FormToken.CSRF_PARAM))) {
resp.sendError(HttpServletResponse.SC_CONFLICT);
return;
}
String size = req.getParameter("pageSize");
if (size == null || !PAGE_SIZES.contains(size)) {
resp.sendError(HttpServletResponse.SC_BAD_REQUEST, "Unknown page size");
return;
}
Cookie cookie = new Cookie("pageSize", size); // a preference: not a secret, not an identity
cookie.setPath(req.getContextPath() + "/forms");
cookie.setMaxAge(60 * 60 * 24 * 365);
cookie.setHttpOnly(true);
cookie.setSecure(req.isSecure());
cookie.setAttribute("SameSite", "Lax");
resp.addCookie(cookie);
Flash.put(req.getSession(), "Showing " + size + " books per page.");
resp.sendRedirect(req.getContextPath() + "/forms/books", HttpServletResponse.SC_SEE_OTHER, true);
}
}
The Set-Cookie header the container wrote, and the attributes that matter:
Set-Cookie: pageSize=5; Expires=Fri, 08 Oct 2027 13:24:47 GMT; Max-Age=31536000; Path=/g7forms/forms; HttpOnly; SameSite=Lax
| Attribute | Meaning | Default to choose |
|---|---|---|
|
Which URLs get the cookie back |
The narrowest path that works ( |
|
Persistent (seconds from now) versus session cookie (omit it) |
Persistent only for preferences; identity cookies stay session cookies |
|
Hides the cookie from JavaScript |
Always, unless the script needs it |
|
Sent only over HTTPS |
Always in production; |
|
Whether a cross-site request carries the cookie |
|
|
Which hosts get the cookie |
Leave unset: a host-only cookie is the narrowest |
On the way back, the servlet validated the value again (PAGE_SIZES.contains(c.getValue())): a request with
Cookie: pageSize=999999 rendered the default page size of 10, and Cookie: pageSize=20 rendered 20 (observed). On the
page, ${cookie.pageSize.value} reads the cookie through EL (c:out with default="none" for a missing one), and it printed
none with no cookie and 999999 with the forged one, which is why the servlet’s value (pageSize), not the raw cookie, is
what the page uses for behaviour.
| Trap | What happens |
|---|---|
The same cookie name sent twice (two paths) |
EL’s |
|
Always null-check before the loop |
A cookie read from the request has no |
Only name and value come back; to change or delete a cookie, send a new one with the same name and path and |
|
Deprecated for removal since Servlet 6.0 (RFC 6265 has no comment or version) |
A cookie holding a user id, role or price |
Anyone can edit it; use the session for identity and the catalogue for prices |
Login: Container Security First
A login form is a form like any other, with two additions: it changes what the session means (before it, anonymous; after it, a user), and its mistakes are exploitable. There are two ways to build one in a servlet application, and the first should be the default.
| Aspect | Container-managed (login-config FORM) |
Application-managed (a filter and a login servlet) |
|---|---|---|
Who checks the credentials |
The container, through a realm (users file, database, LDAP, JAAS) |
Your code |
Who guards the URLs |
|
A filter you write and map |
Session rotation at login |
Done by the container (Tomcat’s |
You call |
Roles |
|
Whatever you store in the session |
Original request restored after login |
Yes, by the specification |
You implement a return URL, and must make it safe |
Portability |
Any servlet container; the realm is container-specific configuration |
Any container; all the code is yours |
Typical use |
Internal tools, admin areas, standard roles |
An existing user table, a custom flow, a single-page app |
The deployment descriptor
Form login is declared in web.xml: which URLs need which roles, how the user authenticates, which pages to show. The
/forms/admin/* area requires the role editor, and the login and error pages live under WEB-INF, where nothing but the
container can reach them.
<security-constraint>
<web-resource-collection>
<web-resource-name>Editors</web-resource-name>
<url-pattern>/forms/admin/*</url-pattern>
</web-resource-collection>
<auth-constraint>
<role-name>editor</role-name>
</auth-constraint>
</security-constraint>
<login-config>
<auth-method>FORM</auth-method>
<realm-name>Bookshelf</realm-name>
<form-login-config>
<form-login-page>/WEB-INF/views/login.jsp</form-login-page>
<form-error-page>/WEB-INF/views/login-error.jsp</form-error-page>
</form-login-config>
</login-config>
<security-role>
<role-name>editor</role-name>
</security-role>
The same area can be protected with an annotation on the servlet instead of XML: @ServletSecurity(@HttpConstraint(rolesAllowed
= "editor")), which the specification maps to a security-constraint. The XML form is the one that can be changed at deployment
without recompiling, and the only form for static resources and JSPs.
The login form has fixed names the specification mandates: the action is j_security_check, the fields are j_username and
j_password, and the container intercepts the POST. The specification adds that the login form "should" carry
autocomplete="off" on the password field; that advice predates password managers, which work better when the field is
declared autocomplete="current-password" (and username for the user field), and the example below follows the managers. The
form is also a plain POST, so it needs no token of its own from the application’s point of view: the container creates the
authenticated session itself.
<%@ page contentType="text/html; charset=UTF-8" pageEncoding="UTF-8" session="false" %>
<form method="post" action="j_security_check">
<p><label for="u">User</label> <input id="u" name="j_username" autocomplete="username" required></p>
<p><label for="p">Password</label> <input id="p" name="j_password" type="password" autocomplete="current-password" required></p>
<button>Sign in</button>
</form>
The realm
The realm is where the container looks users up, and it is container configuration, not part of the WAR’s web.xml. Tomcat
reads it from the context: a META-INF/context.xml inside the WAR or a conf/Catalina/localhost/<name>.xml outside it. The
example uses the Memory Realm with passwords stored as PBKDF2 hashes through a SecretKeyCredentialHandler, and the users file
sits outside the web application.
<Context>
<Realm className="org.apache.catalina.realm.MemoryRealm" pathname="/path/to/bookshelf-users.xml">
<CredentialHandler className="org.apache.catalina.realm.SecretKeyCredentialHandler"
algorithm="PBKDF2WithHmacSHA512" iterations="20000" saltLength="16"/>
</Realm>
</Context>
<tomcat-users xmlns="http://tomcat.apache.org/xml" version="1.0">
<role rolename="editor"/>
<user username="editor1" password="{output of digest.sh for the chosen password}" roles="editor"/>
</tomcat-users>
algorithm and iterations must match the stored hash, and the run above used the script’s 20000 iterations)$ bin/digest.sh -a PBKDF2WithHmacSHA512 -h org.apache.catalina.realm.SecretKeyCredentialHandler 'a-password'
The Tomcat documentation says the Memory Realm is intended solely to get a container-security application running, not for
production use: it has no mechanism for updating the user information. In production the realm is a DataSourceRealm (users in a
database), a JNDIRealm (a directory) or the UserDatabaseRealm fed by the tomcat-users.xml the server already has, and the
production realm is typically wrapped in a LockOutRealm, which locks an account after repeated failures (the default
server.xml already wraps its realm that way). Every realm that stores passwords should hold salted, slow hashes, which is what
the CredentialHandler provides; the OAuth section and the Security page of this section discuss
what to use when the application should not hold passwords at all.
Source: Apache Tomcat 11 — LockOut Realm
What the container does, observed
An anonymous request to a protected URL does not produce a 401 or a redirect on Tomcat: the container saves the request, forwards
to the login page and answers 200 at the original URL. The credentials go to j_security_check; on success the container
rotates the session id and redirects to the saved request (Tomcat 11.0.26 used 303); on failure it serves the error page.
$ curl -s -i -b jar -c jar http://localhost:18080/g7forms/forms/admin/home
HTTP/1.1 200
Set-Cookie: JSESSIONID=3A0C...; Path=/g7forms; HttpOnly
... (the body is the login form: action="j_security_check")
$ curl -s -i -b jar -c jar -d j_username=editor1 -d j_password=wrong http://localhost:18080/g7forms/j_security_check
HTTP/1.1 200 (the body: "Invalid username or password.")
$ curl -s -i -b jar -c jar -d j_username=editor1 -d j_password="$PASSWORD" http://localhost:18080/g7forms/j_security_check
HTTP/1.1 303
Set-Cookie: JSESSIONID=D1CE...; Path=/g7forms; HttpOnly (a different id from the first response)
Location: /g7forms/forms/admin/home
The session id in Set-Cookie changed between the first request and the successful login: that is the container’s defence
against session fixation, in which an attacker plants a known session id in the victim’s browser before login and
rides it afterwards. Tomcat’s changeSessionIdOnAuthentication attribute controls it and defaults to true; leave it so. The
OWASP Session Management Cheat Sheet states the general rule: renew the session id after any privilege-level change.
Reading the user in a servlet or a JSP
After login the container exposes the user through the request, which means through EL as well:
<p>Signed in as <c:out value="${pageContext.request.remoteUser}"/>;
editor role: <c:out value="${pageContext.request.isUserInRole('editor')}"/>;
scheme: <c:out value="${pageContext.request.authType}"/></p>
which printed Signed in as editor1; editor role: true; scheme: FORM (observed). getRemoteUser() returns the login name or
null, getUserPrincipal() the Principal, isUserInRole(role) tests a role, and getAuthType() the scheme. Showing or hiding a button by role is a convenience;
the security-constraint is what stops the request, and the Security page explains why a hidden button is
never an access control.
Logout, and transport security
request.logout() clears the authenticated identity of the request; it does not by itself end the HTTP session, so the logout
servlet also invalidates it. Logout is a state change, so it is a POST protected by the stable CSRF token, and the answer is a
redirect.
@WebServlet("/forms/admin/logout")
public class ContainerLogoutServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) throws ServletException, IOException {
if (!FormToken.checkCsrf(req.getSession(), req.getParameter(FormToken.CSRF_PARAM))) {
resp.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
req.logout(); // forgets the authenticated user for this request
HttpSession session = req.getSession(false);
if (session != null) {
session.invalidate(); // and drop the session that carried it
}
resp.sendRedirect(req.getContextPath() + "/forms/books", HttpServletResponse.SC_SEE_OTHER, true);
}
}
A POST without the token answered 403, with it 303 to the list page, and the next request to /forms/admin/home was served the
login page again (observed). A login form that travels over plain HTTP sends the password in clear text, so the constraint can
require TLS with a user-data-constraint:
<security-constraint>
<web-resource-collection>
<web-resource-name>Secure transport</web-resource-name>
<url-pattern>/forms/secure/*</url-pattern>
</web-resource-collection>
<user-data-constraint>
<transport-guarantee>CONFIDENTIAL</transport-guarantee>
</user-data-constraint>
</security-constraint>
A plain HTTP request to /forms/secure/x answered 302 with Location: https://localhost:18443/g7forms/forms/secure/x: the
container redirects to the connector’s redirectPort (18443 on the scratch server; 8443 in a default Tomcat). Put the
CONFIDENTIAL guarantee on the login and every authenticated URL, not only on one path.
Container security has other methods (BASIC, DIGEST, CLIENT-CERT) and, on a full Jakarta EE server, Jakarta Security 4.0, which
adds an HttpAuthenticationMechanism and an IdentityStore as annotation-driven building blocks; Tomcat does not implement
Jakarta Security. A Spring application uses Spring Security instead (see
Spring Security); the Thymeleaf section’s
Spring Security integration page shows how a template engine reads that
principal.
Source: Jakarta Security 4.0
Login: When the Application Owns It
Sometimes the users live in a table the container does not know, or the login is part of a custom flow. Then the application checks the credentials and keeps the user in the session, and a filter keeps the unauthenticated out of the protected area. Everything the container does has to be done by hand, and the page lists what that is.
| Duty | How the example does it |
|---|---|
Never store or compare a password |
A salted PBKDF2-HMAC-SHA256 hash with 600,000 iterations (the OWASP figure for that algorithm) and |
Do the same work for an unknown user |
Verify against a dummy hash, so the response time does not reveal which user names exist |
Use one message for both failures |
"Invalid username or password."; the OWASP cheat sheet explains why |
Rotate the session id |
|
Return to the page the visitor wanted, safely |
A |
Protect the login form |
The single-use token, like any form that creates state |
Make logout complete |
Invalidate the session, not only remove the user attribute |
Avoid caching authenticated pages |
|
Slow down guessing |
Not shown: a counter per account and per address, or a library; see the cheat sheet |
public class UserStore {
private static final int ITERATIONS = 600_000; // OWASP Password Storage Cheat Sheet, PBKDF2-HMAC-SHA256
private static final SecureRandom RANDOM = new SecureRandom();
private record Credential(byte[] salt, byte[] hash) {
}
private final Map<String, Credential> users = new ConcurrentHashMap<>();
private final Credential dummy = hash("not-a-real-password".toCharArray());
public static UserStore demo(ServletContext ctx) {
UserStore store = new UserStore();
byte[] random = new byte[12];
RANDOM.nextBytes(random);
String password = Base64.getUrlEncoder().withoutPadding().encodeToString(random);
store.users.put("reader", hash(password.toCharArray()));
ctx.log("demo user 'reader' has the generated password " + password); // demo only: never log real ones
return store;
}
private static byte[] derive(char[] password, byte[] salt) {
try {
PBEKeySpec spec = new PBEKeySpec(password, salt, ITERATIONS, 256);
return SecretKeyFactory.getInstance("PBKDF2WithHmacSHA256").generateSecret(spec).getEncoded();
} catch (GeneralSecurityException e) {
throw new IllegalStateException(e);
}
}
/** Does the same amount of work whether or not the user exists, and compares in constant time. */
public boolean verify(String user, char[] password) {
Credential c = user == null ? null : users.get(user);
Credential used = c != null ? c : dummy;
boolean same = MessageDigest.isEqual(used.hash(), derive(password, used.salt()));
return c != null && same;
}
// hash(char[]) generates a random salt and calls derive
}
The demo store has no hard-coded password: it generates one at startup and logs it once, which is how the scratch run obtained
a credential for curl (never do this with real accounts; a real store reads hashes from a database). A request with the right
user and a wrong password and a request with an unknown user both answered 401 in about 80 ms (0.080 s and 0.081 s, observed): the
dummy hash keeps the two indistinguishable by time.
The servlet checks the token, the credentials and the return URL, rotates the session and redirects:
HttpSession session = req.getSession();
if (!FormToken.consume(session, req.getParameter(FormToken.PARAM))) {
resp.sendError(HttpServletResponse.SC_CONFLICT);
return;
}
UserStore users = (UserStore) getServletContext().getAttribute("users");
String name = req.getParameter("username");
String password = req.getParameter("password");
if (name == null || password == null || !users.verify(name, password.toCharArray())) {
req.setAttribute("error", "Invalid username or password."); // the same text for both failures
req.setAttribute("formToken", FormToken.issue(session));
req.setAttribute("returnTo", safeReturn(req.getParameter("returnTo")));
resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
req.getRequestDispatcher("/WEB-INF/views/app-login.jsp").forward(req, resp);
return;
}
req.changeSessionId(); // new id on privilege change: no session fixation
session.setAttribute("user", name);
resp.sendRedirect(req.getContextPath() + safeReturn(req.getParameter("returnTo")),
HttpServletResponse.SC_SEE_OTHER, true);
The return URL is an open redirect waiting to happen
The login page carries the URL to go back to as a returnTo parameter. If the servlet redirects to whatever it receives,
?returnTo=https://evil.example/ sends a freshly authenticated visitor to a look-alike site. The fix is to accept only paths
inside the application and fall back to a fixed page for everything else:
static String safeReturn(String returnTo) {
if (returnTo != null && returnTo.startsWith("/forms/") && !returnTo.startsWith("//")
&& !returnTo.contains("..") && !returnTo.contains("%")
&& returnTo.chars().noneMatch(c -> c == '\\' || c == ':' || Character.isISOControl(c))) {
return returnTo;
}
return "/forms/members/home";
}
returnTo sent |
Redirected to |
|---|---|
|
|
|
|
|
|
|
|
|
|
The first version of the check accepted /forms/..%2f..%2fx (it starts with /forms/); the redirect stays inside the context path
because the servlet prefixes getContextPath(), but a check that passes an encoded traversal is a check that will be reused
somewhere it matters, so .. and % are refused.
The filter
The filter guards /forms/members/*. A request without a user in the session is redirected to the login page with the
wanted path attached; a request with one passes, with a header that keeps browsers and proxies from caching the page.
@WebFilter("/forms/members/*")
public class AuthFilter implements Filter {
@Override
public void doFilter(ServletRequest request, ServletResponse response, FilterChain chain)
throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
HttpServletResponse resp = (HttpServletResponse) response;
HttpSession session = req.getSession(false);
if (session != null && session.getAttribute("user") != null) {
resp.setHeader("Cache-Control", "no-store"); // a logged-in page must not be replayed from a cache
chain.doFilter(request, response);
return;
}
String here = req.getServletPath() + (req.getPathInfo() == null ? "" : req.getPathInfo());
resp.sendRedirect(req.getContextPath() + "/forms/login?returnTo=" + URLEncoder.encode(here, StandardCharsets.UTF_8));
}
}
The run, in order: an anonymous GET /forms/members/home answered 302 to /g7forms/forms/login?returnTo=%2Fforms%2Fmembers%2Fhome;
a wrong password answered 401 with the single message; the right one answered 303 to the wanted page with a new JSESSIONID;
the page then answered 200 with Cache-Control: no-store and "Hello, reader."; a logout POST with the CSRF token
answered 303, and the next request to the members page was redirected to the login again (observed). A request that arrives with a
session id the server never issued is not honoured: Cookie: JSESSIONID=ATTACKERCHOSEN got a Set-Cookie with a different,
server-generated id.
The filter is a guard for this URL space. It does not cover a servlet mapped elsewhere that forgets to check, which is the reason container-managed constraints, which apply to every resource under a pattern whatever serves it, are the safer default (Servlet Fundamentals shows the filter-ordering and dispatcher-type details that decide whether a forward bypasses a filter).
Common Form Tasks from the Books, Done Today
The Web Development with JavaServer Pages chapter on common tasks and the JSTL book’s chapter on the same subject collect recipes that every form application needs. The concepts survive; the implementation moved.
| Recipe | Today | Where on this page or section |
|---|---|---|
Sticky form widgets |
|
|
Checkbox parameters |
|
|
Date assembly and parsing |
|
|
Client versus server validation |
HTML5 constraints for comfort, Bean Validation for truth |
|
Form validation and redisplay |
Collect errors in a map, 422, same view, input kept |
|
|
Unchanged in JSTL; an error page is for the unexpected, validation is for the expected |
|
Shopping cart in the session |
A small |
|
Cookies |
|
|
Application-managed login and logout, role filters |
Prefer container security; otherwise PBKDF2, |
|
Duplicate-submission token |
A synchronizer token, which is also the CSRF defence |
|
|
Needed only when URL rewriting is on; set |
|
Last-modified date of a page |
A servlet’s |
|
JSP plus JavaScript |
Progressive enhancement over a form that already works without script; escape for the JavaScript context when a value goes into a script |
|
|
Never: it is command injection waiting for a parameter |
|
Mail form, banner rotator, directory index |
A servlet with validation (a mail form needs header-injection checks); the others are examples, not designs to copy |
The Same Ideas in Spring MVC, Thymeleaf and Struts
Everything on this page is what a framework does for you. Spring MVC binds request parameters to a command object by name and
type with a WebDataBinder, validates with @Valid on the same Hibernate Validator, collects the result in a BindingResult
that the view reads, and offers RedirectAttributes for PRG; the Thymeleaf pages
Forms and Data Binding and
Validation and Error Messages show that view side, and the Spring MVC with JSP page of this section shows the form: tag library. Struts 2 binds by OGNL expression into action properties, validates
through the validation and workflow interceptors, and redisplays through the same tags (the Struts Validation and UI & Form Tags pages).
The servlet versions here are the reference: when a framework behaves oddly, the getParameter, the session and the redirect
underneath are what it is built from.
Pitfalls and Error Names
| Symptom or error | Cause | Fix |
|---|---|---|
The form posts and nothing is saved, no error |
A field name in the HTML differs from the one the servlet reads ( |
Read names from constants shared with the view, and test a full round trip |
|
No |
Declare UTF-8 in |
A valid form is rejected with 422 for an empty optional field (observed) |
|
|
|
|
|
Two error messages for one field |
A conversion error and the |
Record the conversion error first and |
Errors appear in a different order each time |
The violation set is unordered |
Sort, or iterate in your own field order |
The unticked checkbox is "true" or missing from the model |
An unticked checkbox sends no parameter |
Test |
|
|
|
A redisplayed field shows |
The converted value was shown instead of the typed text |
Keep the raw text next to the converted value |
The password field is refilled |
|
Never emit it |
|
Raw |
|
|
|
|
500 with |
|
Bind in the servlet and turn failures into messages |
A bean field is set that the form never had (observed) |
|
Bind named fields only |
413 with a stack trace in the HTML (observed) |
A multipart body over the limits; |
Call |
|
|
Annotate the servlet or declare |
Double click creates two records |
The submit is not idempotent and nothing identifies the form |
Synchronizer token; a disabled button is only cosmetic |
The flash message never appears (observed) |
A |
Consume it only in |
The flash message appears for the next visitor |
Stored in a static or an application-scoped field, not the session |
Session scope, removed on first read |
A token works for the first of several buttons only |
A single-use token shared by several forms on one page |
A per-session token for repeated forms, one single-use token per rendered form |
A second tab’s form is "expired" |
Only the latest token was kept |
A bounded set of outstanding tokens |
|
A session was first needed after the page had started to write |
Create the session before the first write (servlet, filter) |
Every request or crawler gets |
A JSP started a session by default |
|
Cart contents lost under load |
A check-then-set on a session attribute without a lock |
Create the object once, use synchronized or atomic methods |
|
A non-serializable object in the session |
|
Session timeout surprises users |
|
Check the unit; see Servlet Fundamentals |
A forged cookie value is believed |
The cookie was trusted |
Validate on read; keep identity in the session |
Two cookies of the same name, |
Two paths set the same name |
Distinct names, or iterate |
Login works but the session id is the same before and after |
No rotation: session fixation |
|
Login redirects to an attacker’s site |
An unchecked |
Allowlist of in-application paths (observed) |
Login reveals which users exist |
Different messages or timings |
One message, a dummy hash, equal work (observed) |
A role-protected page opens for everyone |
The constraint’s |
Request the URL without a session and expect the login page (observed) |
Logout leaves the session usable |
Only the user attribute was removed |
|
Passwords visible in logs or the URL |
A login over |
|
What Changed Recently
| Change | Detail |
|---|---|
Servlet 6.1: redirect control |
|
Servlet 6.1: |
A constant for 422, with 308, 421 and 426 (issue 336); a validation redisplay can use the status it means |
Servlet 6.1: parameter parse failures |
Parameter methods are documented to throw a runtime exception when parsing fails (issue 431); on Tomcat that is |
Servlet 6.1: |
|
Servlet 6.0: cookies |
|
Servlet 6.0: removed API |
|
Jakarta Validation 3.1 and Hibernate Validator 9.1 |
Java 17 minimum; the packages are |
Jakarta Security 4.0 (EE 11) |
A standard |
JSTL 3.0 and EL 6.0 |
|
Tomcat 11 FORM login |
The redirect after |
Struts 7.4 |
Client-side JavaScript validation in the |
Where the Books Are Outdated
Web Development with JavaServer Pages and JSTL in Action (both in the section bibliography) build forms with the tools of 2001 to 2003. The concerns in them are still the right concerns; the tools are not.
| The book’s era | Today |
|---|---|
|
|
Scriptlets read parameters and validate ( |
A servlet or a controller binds and validates; the page only displays; scripting can be disabled with |
|
Removed in Servlet 6.0; |
|
|
Validation code written by hand in every servlet, or in JavaScript only |
Bean Validation annotations (Jakarta Validation 3.1, Hibernate Validator 9.1) plus HTML5 constraints |
Hand-made login with passwords kept in beans, the session or a table |
Container security, or salted PBKDF2/bcrypt/Argon2 hashes, or an identity provider (OAuth/OIDC); never a reversible or fast hash |
A duplicate-submission token that only guards against double posts |
A synchronizer token is also the CSRF defence, and |
|
RFC 6265; both methods are deprecated for removal; use |
|
A mass-assignment risk; bind named fields in a servlet |
|
URL rewriting is optional and leaks the id; |
Curly quotes in the book samples (chapters 2 to 4 of the JSP book) |
Not valid Java if copied: another reason not to paste listings from print |
References
-
Jakarta Servlet 6.1 Javadoc — ServletRequest.getParameterValues
-
W3C WAI — ARIA19: Using ARIA role=alert or Live Regions to Identify Errors
-
Hibernate Validator 9.1 — Interpolation with message expressions
-
Jakarta Servlet 6.1 Javadoc — HttpServletResponse.sendRedirect(String, int, boolean)
-
OWASP — CSRF Prevention Cheat Sheet, Synchronizer Token Pattern
-
Jakarta Servlet 6.1 Javadoc — HttpServletResponse.sendError(int, String)
-
Jakarta Servlet 6.1 Javadoc — Cookie.setAttribute(String, String)
-
OWASP — Session Management Cheat Sheet, renew the session id
-
Jakarta Servlet 6.1 Javadoc — HttpServletRequest.isUserInRole
-
OWASP — Authentication Cheat Sheet, authentication and error messages
-
Jakarta Servlet 6.1 Javadoc — HttpServletRequest.changeSessionId
-
OWASP — Unvalidated Redirects and Forwards Cheat Sheet, safe URL redirects