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.

flowchart TD get["GET /forms/book: issue a token, render the empty form"] --> post["POST /forms/book"] post --> size{"Multipart over the limits?"} size -->|"yes"| e413["413 Payload Too Large"] size -->|"no"| tok{"Single-use token valid?"} tok -->|"no: replay, expired, forged"| e409["409 Conflict"] tok -->|"yes"| bind["Bind: strings to a typed bean, conversion errors collected"] bind --> val["Validate with Bean Validation, then the optional file Part"] val --> ok{"Any error?"} ok -->|"yes"| e422["422 and the same form: sticky values, messages, a fresh token"] e422 --> post ok -->|"no"| save["Change state, put a flash message in the session"] save --> r303["303 See Other to the list page"] r303 --> list["GET /forms/books shows the message once"]
Table 1. The status codes the form uses and why
Status Meaning here Notes

200

The form (first GET) or the list page

A POST that answers 200 with a page makes the browser offer to re-send the form on refresh

303

See Other: the answer to a successful POST is a redirect to a GET

Post/Redirect/Get; sendRedirect(location, 303, true) is the Servlet 6.1 way to choose the status

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 maxFileSize or maxRequestSize

422

Unprocessable Content: the data is well-formed but not acceptable, the form is shown again

HttpServletResponse.SC_UNPROCESSABLE_CONTENT, new in Servlet 6.1; older code used 200 or 400

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.

The one dependency this page adds to the Bookshelf pom.xml
<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.

Table 2. The layout of the application (JSPs under WEB-INF, so only servlets can reach them)
Path Role

org.example.bookshelf.BookForm

The form-backing JavaBean, annotated with Bean Validation constraints

BookFormServlet (/forms/book)

Shows the form, binds, validates, saves, redirects; accepts an optional cover Part

BooksServlet (/forms/books)

The list page; reads the pageSize cookie; changes it on POST

CartServlet (/forms/cart)

A session-scoped cart

FormToken, Flash, FlashFilter

The synchronizer token, the flash message and the filter that delivers it

AppListener

Builds the Validator once and stores the catalogue and the user store in the ServletContext

LoginServlet, AuthFilter, UserStore

Application-managed login for /forms/members/*

WEB-INF/views/*.jsp

book-form.jsp, books.jsp, cart.jsp, login.jsp, login-error.jsp, admin.jsp, app-login.jsp, members.jsp

WEB-INF/web.xml, META-INF/context.xml

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.

Table 3. Reading parameters from Java and from EL
API Returns Notes

request.getParameter("title")

The first value, or null if the parameter is absent

An empty text field is present with the empty string, not absent

request.getParameterValues("tags")

A String[] of all values, or null if absent

The way to read checkbox groups and select multiple

request.getParameterMap()

An unmodifiable Map<String, String[]>

The base of every binding library

request.getParameterNames()

An Enumeration<String>

Useful for rejecting unknown fields

${param.title}

The first value, "" when absent

${param} is a Map<String, String>

${paramValues.tags}

The String[]

${paramValues.tags[1]} is the second value; use fn:length for the count

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.

BookFormServlet: one method renders the form for both the first visit and every redisplay
@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.

book-form.jsp: sticky text, radio, checkbox group and single checkbox
<%@ 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:

The redisplayed controls, as the server sent them (observed)
<input id="title" name="title" value="&#034;&gt;&lt;script&gt;alert(1)&lt;/script&gt;" required maxlength="80"
<input id="author" name="author" value="O&#039;Brien &amp; Co" required maxlength="60">

The idioms for the controls differ only in how "selected" is spelled.

Table 4. How each control becomes sticky
Control Idiom Notes

<input type="text">, email, number, date, hidden

value="<c:out value='${form.x}'/>"

Redisplay the text the visitor typed, not the converted value (see below)

<textarea>

<textarea name="x"><c:out value="${form.x}"/></textarea>

No value attribute; the text is the content, and a leading newline is dropped by the browser

<input type="radio">

${form.format == f ? 'checked' : ''}

Compare the stored value with each option’s value

<input type="checkbox"> in a group

${form.tags.contains(t) ? 'checked' : ''}

EL 3 method call on the list; keep the list non-null

a single <input type="checkbox">

${form.newsletter ? 'checked' : ''}

A boolean property

<select>

<option value="${n}" ${pageSize == n ? 'selected' : ''}>

selected, not checked; for select multiple treat it like a checkbox group

<input type="password">

Never refill it

Leave value out: a redisplayed password is sent back over the wire and sits in the page source

<input type="file">

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.

BookFormServlet.bind: only the named fields, conversion errors collected, raw text kept for redisplay
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.

Table 5. Binding rules and why each exists
Rule Reason

Keep the raw text (yearText, publishedText) next to the converted value

The converted value is null after a failed conversion, so redisplay needs the original text; the bean holds both

An unticked checkbox sends nothing

getParameter("newsletter") is null for "off", and the value of a ticked box is whatever its value attribute says (yes here, on if omitted). Test for presence, not for the string "true"

A checkbox group sends the same name repeatedly

Read it with getParameterValues, which is null when no box is ticked; ${param.tags} returns only the first value

Filter multi-values and radio values through an allowlist

A client can send tags=anything; the redisplay and the database must only ever see the values the form offered

Trim, and map blank to null for optional fields

An empty text field arrives as "", which is not null and fails a @Pattern (see the validation pitfalls below)

Read named fields only

Copying every parameter into a bean is mass assignment: a client adds isAdmin=true to the body and the bean accepts it

Convert dates with LocalDate, not SimpleDateFormat

java.time types are immutable and thread-safe; LocalDate.parse("2026-13-40") throws DateTimeParseException

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.

Checking the multi-value behaviour with a throwaway JSP (observed on Tomcat 11.0.26)
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.

Both ways to build a LocalDate fail with a DateTimeException (observed with JDK 21)
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:

A page that binds with the wildcard form (a "before" snippet, for comparison only)
<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.

BookForm: the constraints are on the bean (excerpt)
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
}
Table 6. The built-in constraints a form most often needs (Jakarta Validation 3.1)
Constraint What it checks

@NotNull

The value is not null; an empty string passes

@NotEmpty

Not null and size() > 0 (strings, collections, maps, arrays)

@NotBlank

A string with at least one character that is not whitespace

@Size(min, max)

Length of a string, size of a collection, map or array

@Min, @Max, @Positive, @Negative, @DecimalMin, @DecimalMax, @Digits

Numeric bounds and digit counts

@Pattern(regexp)

A string matches the regular expression in full; null is valid, "" is not unless the pattern allows it

@Email

A loose syntactic e-mail check; it is not a proof that the address exists

@Past, @PastOrPresent, @Future, @FutureOrPresent

Dates and times relative to now

@AssertTrue, @AssertFalse

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.

AppListener: one ValidatorFactory per application, a request-aware locale for the messages
@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.

Violations.byField and the validation step of doPost
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):

The error summary the JSP rendered
<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.

ValidationMessages.properties (excerpt)
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.

Table 7. Validation behaviours that surprise people
Behaviour What to do about it

@Pattern and @Size accept null but reject "" if the pattern or minimum requires text

A text field the visitor leaves empty arrives as "". For an optional field map blank to null while binding (blankToNull above); the first version of this form answered 422 for a valid book because "" failed the optional ISBN’s @Pattern

@NotNull accepts ""

Use @NotBlank for required text, @NotNull for required objects and numbers

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 @AssertTrue method isAudioWithoutIsbn() reports under audioWithoutIsbn, not under isbn. A custom class-level constraint can attach the violation to a field with addPropertyNode

The same bean, different rules for different buttons

Validation groups: the "Publish" button validates Default plus BookForm.Publish, the "Save draft" button only Default

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.

Table 8. Presenting errors accessibly
Element Purpose

A summary with role="alert" at the top of the form

Screen readers announce it when the page loads; each item links to its field (href="#year")

A message element next to each field, with an id

The field points to it with aria-describedby, and aria-invalid tells assistive technology the value is wrong

The visitor’s input, redisplayed

Never make a visitor retype a long form because one field was wrong

A <label for> for every control

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.

Table 9. Client-side versus server-side validation
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: curl, a modified page, a disabled script, novalidate

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:

The end of doPost: save, remember a message, redirect with 303
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:

Flash and FlashFilter: written once, read once
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):

A valid draft with a PNG cover: 303, then the list page once with the message, then without it (observed)
$ 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

Three details of flash messages decide whether they work.

Table 10. Flash message rules
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 POST that arrived before the page view consumed it, and the visitor never saw it (observed). A GET is the request that renders a page

Never create a session just to look for a message

getSession(false) returns null when there is none; getSession() would give every anonymous visitor, and every crawler, a Set-Cookie

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.

FormToken: single-use tokens for forms, a stable token for forms that repeat (excerpt)
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.

Table 11. Two kinds of token and which form uses which
Kind Used by Lifetime and behaviour

Single-use (formToken, consume)

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 (csrf, checkCsrf)

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.

The first lines of doPost: read the parts before anything else
// 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.

BookFormServlet.checkCover: the first bytes decide, not the file name or the declared type
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.

Table 12. What belongs in the session, and what does not
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 Book list, a database entity, a Connection

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:

Cart: a Serializable, synchronized session object that stores ids, never entities
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.

CartServlet: validate, update the cart under a lock, flash, 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).

cart.jsp: the session cart read through EL
<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.

The same page text with and without the directive (observed on Tomcat 11.0.26)
<%-- 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.

BooksServlet: validate before storing, validate again when reading
@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
Table 13. Cookie attributes and the secure default for each
Attribute Meaning Default to choose

Path

Which URLs get the cookie back

The narrowest path that works (/g7forms/forms), not /

Max-Age / Expires

Persistent (seconds from now) versus session cookie (omit it)

Persistent only for preferences; identity cookies stay session cookies

HttpOnly

Hides the cookie from JavaScript

Always, unless the script needs it

Secure

Sent only over HTTPS

Always in production; req.isSecure() makes the demo work over plain HTTP

SameSite (Lax, Strict, None)

Whether a cross-site request carries the cookie

Lax; None requires Secure; set with Cookie.setAttribute("SameSite", ...) in Servlet 6.0 and later

Domain

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.

Table 14. Cookie traps seen on Tomcat 11.0.26
Trap What happens

The same cookie name sent twice (two paths)

EL’s cookie map shows one of them (Cookie: pageSize=20; pageSize=5 printed cookie: 20 while the servlet’s loop ended on 5); iterate request.getCookies() when the order matters, and use distinct names

request.getCookies() returns null, not an empty array

Always null-check before the loop

A cookie read from the request has no Path, Max-Age or SameSite

Only name and value come back; to change or delete a cookie, send a new one with the same name and path and Max-Age=0

Cookie.setComment, setVersion

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.

Table 15. Two ways to log in
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

security-constraint elements in web.xml, or @ServletSecurity

A filter you write and map

Session rotation at login

Done by the container (Tomcat’s changeSessionIdOnAuthentication defaults to true)

You call request.changeSessionId()

Roles

<security-role>, request.isUserInRole, getRemoteUser(), getUserPrincipal()

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.

web.xml: a security constraint, FORM login and the role it names
<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.

login.jsp: the fixed field names and action, no session started by this page
<%@ 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.

META-INF/context.xml: a Memory Realm with hashed passwords (the file path is a placeholder)
<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>
bookshelf-users.xml: the user and the role (the hash is generated, never typed)
<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>
Generating the hash with Tomcat’s digest script (the output has the form salt$iterations$hash; the handler’s 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.

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.

sequenceDiagram participant B as Browser participant C as Tomcat (FORM authenticator) participant R as Realm participant P as Protected servlet B->>C: GET /forms/admin/home (no principal) C-->>B: 200 login page, request saved in the session B->>C: POST j_security_check (j_username, j_password) C->>R: authenticate(user, password) alt wrong credentials R-->>C: no principal C-->>B: 200 login-error page else valid R-->>C: principal and roles C->>C: change the session id (session fixation defence) C-->>B: 303 to /forms/admin/home, new JSESSIONID B->>C: GET /forms/admin/home C->>P: request with remoteUser and roles P-->>B: 200 page end
The whole exchange with curl and a cookie jar (observed on Tomcat 11.0.26)
$ 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:

admin.jsp: the principal, a role test and the authentication scheme
<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.

ContainerLogoutServlet: forget the user, drop the session, 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:

web.xml: require HTTPS for a path
<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.

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.

Table 16. What an application-managed login must do
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 MessageDigest.isEqual

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

request.changeSessionId() after a successful check

Return to the page the visitor wanted, safely

A returnTo parameter checked against an allowlist of paths inside the application

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

Cache-Control: no-store from the filter

Slow down guessing

Not shown: a counter per account and per address, or a library; see the cheat sheet

UserStore: salted slow hashes and a dummy comparison (excerpt)
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:

LoginServlet.doPost: token, credentials, rotation, safe redirect
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:

LoginServlet.safeReturn: an allowlist of path shapes
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";
}
Table 17. Return URLs tried against the login servlet (observed): only in-application paths survive
returnTo sent Redirected to

https://evil.example/

/g7forms/forms/members/home (the fallback)

//evil.example/x

/g7forms/forms/members/home

/forms/..%2f..%2fx

/g7forms/forms/members/home

/other

/g7forms/forms/members/home

/forms/members/home?x=1

/g7forms/forms/members/home?x=1

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.

AuthFilter: no user in the session, no entry
@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.

Table 18. The recurring recipes and where each lives now
Recipe Today Where on this page or section

Sticky form widgets

c:out into the attribute, checked/selected by EL; never a scriptlet

Showing the form

Checkbox parameters

getParameterValues, ${paramValues.x}, an allowlist; an unticked box is absent

Binding

Date assembly and parsing

type="date" plus LocalDate.parse, or LocalDate.of with DateTimeException handling; fmt:parseDate for pages

Binding

Client versus server validation

HTML5 constraints for comfort, Bean Validation for truth

Validation

Form validation and redisplay

Collect errors in a map, 422, same view, input kept

Validation

c:catch and error pages with pageContext.exception

Unchanged in JSTL; an error page is for the unexpected, validation is for the expected

Error Handling

Shopping cart in the session

A small Serializable object of ids and counts; PRG; a token

Sessions

Cookies

Cookie with HttpOnly, Secure, SameSite, a narrow Path; validate on read

Cookies

Application-managed login and logout, role filters

Prefer container security; otherwise PBKDF2, changeSessionId, a return-URL allowlist

Login

Duplicate-submission token

A synchronizer token, which is also the CSRF defence

Duplicate submissions

encodeURL and encodeRedirectURL for cookie-less clients

Needed only when URL rewriting is on; set <tracking-mode>COOKIE</tracking-mode> and treat a session id in a URL as a leak

Session security

Last-modified date of a page

A servlet’s getLastModified for conditional GET, or an ETag; not a JSP expression

HTTP methods

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

Security page

Runtime.exec from a page

Never: it is command injection waiting for a parameter

Security page

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

Standard Actions & JavaBeans

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

Table 19. Form, validation and session pitfalls (rows marked "observed" were reproduced on Tomcat 11.0.26)
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 (pageSize versus pagesize)

Read names from constants shared with the view, and test a full round trip

é becomes é in the saved value

No <request-character-encoding>; the body was decoded as ISO-8859-1

Declare UTF-8 in web.xml (character encoding page)

A valid form is rejected with 422 for an empty optional field (observed)

"" fails @Pattern; the field is not null

blankToNull while binding

@NotNull passes an empty text field

"" is not null

@NotBlank for text

Two error messages for one field

A conversion error and the @NotNull of the unconverted value

Record the conversion error first and putIfAbsent the violations

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 getParameter(name) != null, or send a hidden marker

${param.tags} shows one value

param is the first value of each name

paramValues, getParameterValues

A redisplayed field shows abc as 0 or empty

The converted value was shown instead of the typed text

Keep the raw text next to the converted value

The password field is refilled

value set on a password input

Never emit it

<script> in a field runs on redisplay

Raw ${form.title} in an attribute or in the body

c:out (or fn:escapeXml) for every value that came from the request

ELException: Failed to parse the expression [${pageContext.session.new}] (observed)

new is an EL reserved word

${pageContext.session['new']}

500 with NumberFormatException from a page (observed)

jsp:setProperty conversion of a non-number

Bind in the servlet and turn failures into messages

A bean field is set that the form never had (observed)

jsp:setProperty property="*" binds every matching parameter

Bind named fields only

413 with a stack trace in the HTML (observed)

A multipart body over the limits; getParameter threw InvalidParameterException (a Servlet 6.1 IllegalStateException)

Call getParts() first inside a try, answer with your own 413

getParameter returns null for every field of a multipart form (observed)

@MultipartConfig is missing: Tomcat 11.0.26 parsed no parts (getParts() on a urlencoded request threw IllegalStateException: Unable to process parts as no multi-part configuration has been provided), or a filter read the body first

Annotate the servlet or declare <multipart-config>; do not read the body in a filter

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 POST or an asset request consumed it before the page view

Consume it only in GET page views

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

IllegalStateException: getSession(): the response has been committed

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 Set-Cookie: JSESSIONID (observed)

A JSP started a session by default

session="false", and getSession(false) for lookups

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

NotSerializableException or lost sessions after a restart

A non-serializable object in the session

Serializable, small, no connections or entities

Session timeout surprises users

<session-timeout> is minutes, setMaxInactiveInterval is seconds

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, cookie.x shows the wrong one (observed)

Two paths set the same name

Distinct names, or iterate getCookies()

Login works but the session id is the same before and after

No rotation: session fixation

request.changeSessionId(); container login does it (observed)

Login redirects to an attacker’s site

An unchecked returnTo

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 url-pattern does not match, or the role is not declared

Request the URL without a session and expect the login page (observed)

Logout leaves the session usable

Only the user attribute was removed

request.logout() and session.invalidate() (observed)

Passwords visible in logs or the URL

A login over GET, or logging of parameters

POST only, never log credentials; the demo’s one-time log line is for a demo user only

What Changed Recently

Table 20. Changes that affect forms, validation and sessions
Change Detail

Servlet 6.1: redirect control

sendRedirect(location, status, clearBuffer) makes a 303 for PRG explicit (issue 74); the older sendRedirect(location) sends 302

Servlet 6.1: SC_UNPROCESSABLE_CONTENT

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 InvalidParameterException, an IllegalStateException

Servlet 6.1: HttpSession.Accessor

getAccessor() lets code outside a request (typically WebSocket) work with a session

Servlet 6.0: cookies

Cookie.setAttribute/getAttributes handle SameSite and any other attribute; setComment and setVersion are deprecated for removal

Servlet 6.0: removed API

HttpSessionContext, HttpUtils, SingleThreadModel and the deprecated session methods getValue/putValue are gone

Jakarta Validation 3.1 and Hibernate Validator 9.1

Java 17 minimum; the packages are jakarta.validation; message interpolation needs a Jakarta EL implementation, which Tomcat provides

Jakarta Security 4.0 (EE 11)

A standard HttpAuthenticationMechanism and IdentityStore on full EE servers; Tomcat does not implement it, so container login there is the Servlet FORM mechanism

JSTL 3.0 and EL 6.0

jakarta.tags.core URIs, record components as EL properties (observed with line.key.title)

Tomcat 11 FORM login

The redirect after j_security_check was a 303 in the run above

Struts 7.4

Client-side JavaScript validation in the xhtml and css_xhtml themes is deprecated (WW-5694)

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.

Table 21. Form and session material of the book era, and today
The book’s era Today

javax.servlet.* and javax.servlet.jsp.*

jakarta.servlet.*, jakarta.servlet.jsp.*, jakarta.tags.core; Tomcat 10 and later do not run the old names

Scriptlets read parameters and validate (request.getParameter, if blocks in the page)

A servlet or a controller binds and validates; the page only displays; scripting can be disabled with scripting-invalid

HttpUtils.getRequestURL(request) to rebuild a URL

Removed in Servlet 6.0; request.getRequestURL()

SingleThreadModel or isThreadSafe="false" to make a servlet "safe"

SingleThreadModel was removed in Servlet 6.0 and isThreadSafe in Pages 4.0 (a translation error); make shared state thread-safe

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 SameSite cookies are a second layer

Cookie.setComment, setVersion, cookie versions 0 and 1

RFC 6265; both methods are deprecated for removal; use setAttribute("SameSite", ...)

<jsp:setProperty property="*"/> as the standard way to bind

A mass-assignment risk; bind named fields in a servlet

encodeURL everywhere for cookie-less browsers

URL rewriting is optional and leaks the id; <tracking-mode>COOKIE</tracking-mode>

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