What this post covers
This is the eleventh post in the "Digging into Tomcat's Source" series. In part 10 we watched a WAR become a running StandardContext. This time we look at how a web app remembers a user — the session. When a servlet calls request.getSession(), the response picks up a Set-Cookie: JSESSIONID=… header, and from the next request on, the same session object comes back. We follow what happens in between.
There are four questions to answer.
- Where does Tomcat read the session ID a request brings, and when is a session object created?
- How are the 32 characters of
JSESSIONIDmade, and who decides the cookie'sPathandHttpOnly? - When exactly does a session expire, and which thread removes it?
- Does a session survive a Tomcat restart?
| Item | Details |
|---|---|
| Target | Apache Tomcat |
| Version | 12.0.0-M1 (main) |
| Commit | 17aa1769b0 (2026-10-23) |
| Main classes | connector/Request, connector/CoyoteAdapter, session/ManagerBase, session/StandardManager, session/StandardSession, util/StandardSessionIdGenerator |
All line numbers in this post refer to this commit. How to get the same source and build the Docker image for the examples is in part 1. Comparing with the stable 11.0.x branch, the files ManagerBase, StandardManager and StandardSession in the session package, the ID generators and the cookie configuration class are identical, and the session-handling parts of Request, CoyoteAdapter and Response are the same. Everything in this post applies to 11.0.x as well.
The session package alone is twelve Java files and more than 5,800 lines. This post picks only the main path a newcomer can follow. Things left out, such as cluster replication, are listed by name and location in "What this post leaves out" at the end.
Terms to know first
A session is server-side storage that ties several HTTP requests together as "from the same user". Every HTTP request stands on its own, so the server attaches a ticket called a session ID to its response and has the browser bring that ticket back with the next request. The server finds the storage by that ID and knows it is "the same person as before". Shopping carts and login state live here.
JSESSIONID is the default session cookie name defined by the Servlet specification. When cookies can't be used, the ID travels at the end of the URL as ;jsessionid=…; this is called URL rewriting. The ;name=value part of a URL is a path parameter, and how Tomcat strips it off while decoding the URI was covered in part 7.
A Manager (session manager) is the Tomcat component that owns all sessions of one web app. It is the <Manager> element you write inside <Context> in server.xml or context.xml. If you write nothing, a StandardManager is used. There is one per web app (Context), so sessions of /app1 and /app2 know nothing about each other. What a Context is was covered in part 2.
maxInactiveInterval answers "if no request arrives for this many seconds after the last one, should we drop the session?". The <session-timeout> in web.xml (in minutes) becomes this value. Tomcat's conf/web.xml sets it to 30 minutes (conf/web.xml lines 613–615).
Serialization turns a Java object into bytes so it can be written to a file or sent over a network. Only objects that implement java.io.Serializable can be serialized the default way. It is used when sessions are saved to disk.
The background process is the periodic housekeeping Tomcat's containers do. Detecting class changes (the reload from part 4), auto-deployment (part 10), and the session expiry check in this post all run at this time.
1. The big picture: one Manager per web app, one map inside it
Here are the classes involved in sessions on one page.
Yellow boxes are the places that actually hold something. The heart of session storage is a single map in memory.
For a web app with no <Manager> of its own, StandardContext.startInternal() creates a StandardManager and attaches it at startup (core/StandardContext.java lines 4542–4567; line 4557 is new StandardManager()). If there is a cluster and the web app is <distributable/>, a cluster manager is created instead, which this post does not cover.
The place that holds the sessions is a single field of ManagerBase.
protected Map<String,Session> sessions = new ConcurrentHashMap<>();
java/org/apache/catalina/session/ManagerBase.java line 147
It is a map from session ID to StandardSession object. Many request threads create and look up sessions at the same time, hence ConcurrentHashMap. Looking a session up is nothing more than reading this map.
@Override
public Session findSession(String id) throws IOException {
if (id == null) {
return null;
}
return sessions.get(id);
}
java/org/apache/catalina/session/ManagerBase.java lines 752–758
findSession() does not look at expiry; if the session is in the map, it returns it. The caller checks expiry separately with isValid(). This becomes important in section 4.
A servlet does not get the StandardSession itself
StandardSession implements both the Servlet API's HttpSession and Tomcat's internal Session interface (StandardSession.java line 68). The internal one has methods a web app must not touch, such as setId(), expire() and setManager(). So servlets are given a shell, StandardSessionFacade, that implements only HttpSession (StandardSession.java lines 467–473, StandardSessionFacade.java line 27). It is the same reason as the RequestFacade we saw in part 7. Example 1's output shows class : org.apache.catalina.session.StandardSessionFacade.
2. How a request brings its session ID
CoyoteAdapter pulls the ID out early
Reading the session ID happens long before the servlet, in CoyoteAdapter.postParseRequest(), which we saw in part 7 — right after the Mapper has picked this request's web app (Context). Each web app can configure its own cookie name and tracking modes, so the ID can only be read once the web app is known.
// Now we have the context, we can parse the session ID from the URL
// (if any). Need to do this before we redirect in case we need to
// include the session id in the redirect
String sessionID;
if (request.getServletContext().getEffectiveSessionTrackingModes().contains(SessionTrackingMode.URL)) {
// Get the session ID if there was one
sessionID = request.getPathParameter(SessionConfig.getSessionUriParamName(request.getContext()));
if (sessionID != null) {
request.setRequestedSessionId(sessionID);
request.setRequestedSessionURL(true);
}
}
// Look for session ID in cookies and SSL session
try {
parseSessionCookiesId(request);
} catch (IllegalArgumentException e) {
// Too many cookies
if (!response.isError()) {
response.setError();
response.sendError(400, e.getMessage());
}
return true;
}
parseSessionSslId(request);
java/org/apache/catalina/connector/CoyoteAdapter.java lines 716–741
The order matters. First the URL's ;jsessionid=, then the cookies. Inside parseSessionCookiesId() there is code that overwrites with the cookie value, under the comment "Override anything requested in the URL" (lines 1027–1033). When both are present, the cookie wins (as long as cookie tracking is on for the web app; if it is off, cookies are not read at all, lines 1010–1013). If there are several cookies with the same name, the first one is taken, but if it isn't valid, the next one is tried (lines 1037–1043).
All this step does is write a string into the requestedSessionId field. It does not check yet whether a session with that ID actually exists. The cookie and parameter names come from SessionConfig: <Context sessionCookieName="…"> → web.xml's <cookie-config><name> → the default, and the defaults are these two constants.
private static final String DEFAULT_SESSION_COOKIE_NAME = "JSESSIONID";
private static final String DEFAULT_SESSION_PARAMETER_NAME = "jsessionid";
java/org/apache/catalina/util/SessionConfig.java lines 28–29
Which tracking modes are on is decided by ApplicationContext. URL rewriting is always on, and cookies are on when <Context cookies="true"> (the default) (core/ApplicationContext.java lines 888–896). You can change this set with <tracking-mode> in web.xml.
Request.doGetSession() — find one, or make one
When a servlet calls request.getSession(), it goes through getSession(true) in connector/Request and reaches doGetSession(true) (lines 2136–2149). Every path to a session meets in this one method.
Yellow boxes are values already in hand. getSession() searches the map only the first time it is called in a request.
Here is the core of it.
if (requestedSessionId != null) {
try {
session = manager.findSession(requestedSessionId);
} catch (IOException ioe) {
if (log.isDebugEnabled()) {
log.debug(sm.getString("request.session.failed", requestedSessionId, ioe.getMessage()), ioe);
} else {
log.info(sm.getString("request.session.failed", requestedSessionId, ioe.getMessage()));
}
session = null;
}
if ((session != null) && !session.isValid()) {
session = null;
}
if (session != null) {
session.access();
// The client has chosen to join the session
session.setNew(false);
return session;
}
}
// Create a new session if requested and the response is not committed
if (!create) {
return null;
}
// ... throw if the response is already out; decide whether to reuse the client's ID ...
session = manager.createSession(sessionId);
// Creating a new session cookie based on that session
if (session != null && trackModesIncludesCookie) {
Cookie cookie =
ApplicationSessionCookieConfig.createSessionCookie(context, session.getIdInternal(), isSecure());
response.addSessionCookieInternal(cookie);
}
java/org/apache/catalina/connector/Request.java lines 2692–2770
- If this request has already fetched a session (the
sessionfield), return it (lines 2679–2685). CallinggetSession()many times in one request searches the map only once. - If there is a
requestedSessionId, look it up in the map and check expiry withisValid(). If it is alive, mark it "in use now" withaccess()and return it. - If it is missing or expired and
createisfalse, returnnull.getSession(false)ends here. - If
createistrue, make a new one withmanager.createSession(), and if cookie tracking is on, addSet-Cookieto the response.
A session exists only once someone calls getSession(). JSPs create a session by default (that <%@ page session="true" %> is the default comes from the JSP specification; we look at Jasper's source in part 13), but a web app with only servlets has no session and no cookie unless it asks for one.
The ID the client sends is almost never reused
The elided part (lines 2724–2761) decides "when making a new session, should we use the ID the client sent?". The answer is almost always no (sessionId = null → a fresh ID). It is reused in only two cases.
- The ID came from the TLS session (
requestedSessionSSL). - The cookie path was set to
/directly on theContext, as in<Context sessionCookiePath="/">, and the ID came from a cookie. This is for several web apps on one host sharing one ID. Even then, ifvalidateClientProvidedNewSessionIdis on (defaulttrue,StandardContext.javaline 785), the ID is used only if some web app on the same host really has a session with it.
The reason for blocking this is session fixation. An attacker plants JSESSIONID=ABC123 in the victim's browser, gets the victim to log in with that ID, and then walks in with the same ID. If the server accepted any ID, this would work. We check it in step 5 of example 1.
Example 1: the session ID and the cookie
This example was run in a Docker container (Eclipse Temurin JDK 21) on Tomcat 12.0.0-M1-dev, built as-is from the reference commit (17aa1769b0). One servlet prints the ID the request brought and the state of the session (ex/u11-session-id).
// An example written for this post (not part of Tomcat's source)
// ... package and imports omitted ...
@WebServlet("/s")
public class SessionServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) throws IOException {
resp.setContentType("text/plain");
PrintWriter out = resp.getWriter();
// The session ID the request brought — CoyoteAdapter already pulled it from the cookie or the URL's ;jsessionid=
out.println("requested id : " + req.getRequestedSessionId()
+ " (cookie=" + req.isRequestedSessionIdFromCookie()
+ ", url=" + req.isRequestedSessionIdFromURL()
+ ", valid=" + req.isRequestedSessionIdValid() + ")");
// With ?create=0 we call getSession(false) — it does not create one
boolean create = !"0".equals(req.getParameter("create"));
HttpSession session = req.getSession(create);
if (session == null) {
out.println("session : null");
return;
}
// ... bump the session's count attribute and print id, isNew, maxInactive and the class name ...
// URL rewriting — adds ;jsessionid= unless the ID came in a cookie
out.println("encodeURL : " + resp.encodeURL("/u11/s"));
}
}
[Shortened from the example ex/u11-session-id/src/ex/u11/SessionServlet.java]
We sent five requests with curl, keeping only the status line, the Set-Cookie header and the body.
===== 1. GET /u11/s?create=0 (getSession(false)) =====
HTTP/1.1 200
requested id : null (cookie=false, url=false, valid=false)
session : null
===== 2. GET /u11/s (getSession() - first time) =====
HTTP/1.1 200
Set-Cookie: JSESSIONID=E221CAF5F85FF1E04892CC5FFAC94FAF; Path=/u11; HttpOnly
requested id : null (cookie=false, url=false, valid=false)
session id : E221CAF5F85FF1E04892CC5FFAC94FAF (32 chars)
isNew : true
count : 1
maxInactive : 1800 s
class : org.apache.catalina.session.StandardSessionFacade
encodeURL : /u11/s;jsessionid=E221CAF5F85FF1E04892CC5FFAC94FAF
===== 3. GET /u11/s with the cookie =====
HTTP/1.1 200
requested id : E221CAF5F85FF1E04892CC5FFAC94FAF (cookie=true, url=false, valid=true)
session id : E221CAF5F85FF1E04892CC5FFAC94FAF (32 chars)
isNew : false
count : 2
maxInactive : 1800 s
class : org.apache.catalina.session.StandardSessionFacade
encodeURL : /u11/s
===== 4. GET /u11/s;jsessionid=<id> without a cookie =====
HTTP/1.1 200
requested id : E221CAF5F85FF1E04892CC5FFAC94FAF (cookie=false, url=true, valid=true)
session id : E221CAF5F85FF1E04892CC5FFAC94FAF (32 chars)
isNew : false
count : 3
maxInactive : 1800 s
class : org.apache.catalina.session.StandardSessionFacade
encodeURL : /u11/s;jsessionid=E221CAF5F85FF1E04892CC5FFAC94FAF
===== 5. GET /u11/s with a made-up cookie JSESSIONID=ABC123 =====
HTTP/1.1 200
Set-Cookie: JSESSIONID=C74FE9B92C22274296B4370082622AEA; Path=/u11; HttpOnly
requested id : ABC123 (cookie=true, url=false, valid=false)
session id : C74FE9B92C22274296B4370082622AEA (32 chars)
isNew : true
count : 1
maxInactive : 1800 s
class : org.apache.catalina.session.StandardSessionFacade
encodeURL : /u11/s
The session ID values differ on every run.
- Calling only
getSession(false)creates neither a session nor aSet-Cookie. - The first
getSession()creates a 32-character ID and aSet-Cookie.Path=/u11is the web app's path, andHttpOnlytells the browser not to let JavaScript read this cookie (section 3).maxInactiveis the 30 minutes = 1800 seconds fromconf/web.xml. - Sending the cookie back joins the same session, and
isNewbecomesfalse. No newSet-Cookiearrives. - With no cookie and only
;jsessionid=on the URL, it is still the same session (url=true).countcontinues at 3. - Sending a made-up ID
ABC123givesvalid=false, and the session is created with a new ID, not that one. This is the session fixation defense from above.
Easy to get confused
- In step 2,
encodeURLadded;jsessionid=— even though a cookie was set. When there is a session,Response.isEncodeable()checks "did the requested ID come from a cookie?" and does not look at whether this response carries aSet-Cookie(connector/Response.javalines 1378–1384). On the first response, the server cannot know whether the browser will accept the cookie, so it sends the ID both ways. Once the cookie comes back, as in step 3, it stops. This is why;jsessionid=shows up only in the links of the first page. - In step 5,
encodeURLleaves the URL alone. The ID was wrong, but it still "came from a cookie", so by the same rule nothing is added. - You can't create a session after the response has started going out. If cookie tracking is on and the response is committed (headers already sent), you get an
IllegalStateException(lines 2718–2722), because there is no room left forSet-Cookie. It happens in code that callsgetSession()halfway through writing a large response.
3. Making the session ID and the cookie
createSession()
ManagerBase.createSession() is short.
@Override
public Session createSession(String sessionId) {
if ((maxActiveSessions >= 0) && (getActiveSessions() >= maxActiveSessions)) {
rejectedSessions++;
throw new TooManyActiveSessionsException(sm.getString("managerBase.createSession.ise"), maxActiveSessions);
}
// Recycle or create a Session instance
Session session = createEmptySession();
// Initialize the properties of the new session and return it
session.setNew(true);
session.setValid(true);
session.setCreationTime(System.currentTimeMillis());
session.setMaxInactiveInterval(getContext().getSessionTimeout() * 60);
String id = sessionId;
if (id == null) {
id = generateSessionId();
}
session.setId(id);
SessionTiming timing = new SessionTiming(session.getCreationTime(), 0);
synchronized (sessionCreationTiming) {
sessionCreationTiming.add(timing);
sessionCreationTiming.poll();
}
return session;
}
java/org/apache/catalina/session/ManagerBase.java lines 715–743
- Going over
<Manager maxActiveSessions="…">throws. The default is-1(no limit, line 159). createEmptySession()goes throughgetNewSession()tonew StandardSession(this)(lines 747–749, lines 886–888). The comment says "Recycle", but no object is reused; a new one is made every time.- The timeout is the web app's
sessionTimeout(minutes) × 60 seconds.StandardContextalso defaults to 30 minutes (line 525), and if web.xml has<session-timeout>,ContextConfigsets it to that value (startup/ContextConfig.javaline 1600). - It is
setId()that puts the session in the map. You won't see a separateadd()call:StandardSession.setId()removes the old ID from the map, callsmanager.add(this)with the new one, and callstellNew(), which callsHttpSessionListener.sessionCreated()(StandardSession.javalines 282–297, lines 303–333). SosessionCreated()runs on the request thread, beforeSet-Cookieis added. That is thecreated … on http-nio-8080-exec-…in example 2.
32 characters — 16 bytes of SecureRandom
generateSessionId() hands the work to an ID generator (lines 896–898). If none is configured, the Manager creates a StandardSessionIdGenerator at startup (lines 659–663).
@Override
public String generateSessionId(String route) {
byte[] random = new byte[16];
int sessionIdLength = getSessionIdLength();
// Render the result as a String of hexadecimal digits
// Start with enough space for sessionIdLength and medium route size
StringBuilder buffer = new StringBuilder(2 * sessionIdLength + 20);
int resultLenBytes = 0;
while (resultLenBytes < sessionIdLength) {
getRandomBytes(random);
for (int j = 0; j < random.length && resultLenBytes < sessionIdLength; j++) {
byte b1 = (byte) ((random[j] & 0xf0) >> 4);
byte b2 = (byte) (random[j] & 0x0f);
if (b1 < 10) {
buffer.append((char) ('0' + b1));
} else {
buffer.append((char) ('A' + (b1 - 10)));
}
if (b2 < 10) {
buffer.append((char) ('0' + b2));
} else {
buffer.append((char) ('A' + (b2 - 10)));
}
resultLenBytes++;
}
}
if (route != null && !route.isEmpty()) {
buffer.append('.').append(route);
} else {
String jvmRoute = getJvmRoute();
if (jvmRoute != null && !jvmRoute.isEmpty()) {
buffer.append('.').append(jvmRoute);
}
}
return buffer.toString();
}
java/org/apache/catalina/util/StandardSessionIdGenerator.java lines 32–73
It takes sessionIdLength random bytes (16 by default, SessionIdGeneratorBase.java line 92) and writes each byte as two characters, 0–9 and A–F, four bits at a time. 16 bytes = 128 bits = 32 upper-case hexadecimal characters. That is the (32 chars) in example 1.
The ID and cookie are the real output of example 1. The byte values are that ID read back two characters at a time. The cookie attributes are covered in the table at the end of this section.
The .route at the end is for clusters. If you write <Engine jvmRoute="node1"> in server.xml, the ID becomes …AEA.node1 (the value is taken from the Engine at ManagerBase.java lines 925–928). It is a marker so that a load balancer in front can read the part after . and send the user back to the same Tomcat. httpd's mod_proxy_balancer does exactly that (compared at the end of this post).
The random bytes come from SecureRandom.
protected void getRandomBytes(byte[] bytes) {
SecureRandom random = randoms.poll();
if (random == null) {
random = createSecureRandom();
}
random.nextBytes(bytes);
randoms.add(random);
}
java/org/apache/catalina/util/SessionIdGeneratorBase.java lines 227–235
If many threads shared one SecureRandom, they would queue on its internal lock. So a queue named randoms (ConcurrentLinkedQueue, line 73) is used as a pool: take one, use it, put it back, and make a new one if the pool is empty. The more threads create sessions at once, the more SecureRandom instances there will be. The algorithm is SHA1PRNG if the JDK has it (lines 56–64). SecureRandom is the JDK class for "unpredictable" random numbers; the point of these 128 bits is to stop someone from guessing a session ID and walking into another user's session.
When the Manager starts, it calls generateSessionId() once in advance (SessionIdGeneratorBase.startInternal(), lines 307–313). The first initialization of a SecureRandom can be slow while it gathers entropy from the operating system, and this keeps the first user's request from paying for it. If creating one takes more than 100 ms, a warning is logged (lines 294–296). The message Creation of SecureRandom instance for session ID generation using [SHA1PRNG] took [...] milliseconds. that you may see in the startup log of an older Linux server is this warning.
Easy to get confused: there is no duplicate check
Neither createSession() nor generateSessionId() has a loop asking "is this ID already in the map?". The design treats a collision of 128 random bits as negligible. If one ever happened, sessions.put() in setId() would overwrite the existing session.
Who decides the cookie's attributes
The single Set-Cookie line is built by ApplicationSessionCookieConfig.createSessionCookie().
Cookie cookie = new Cookie(SessionConfig.getSessionCookieName(context), sessionId);
// Just apply the defaults.
cookie.setMaxAge(scc.getMaxAge());
if (context.getSessionCookieDomain() == null) {
// Avoid possible NPE
if (scc.getDomain() != null) {
cookie.setDomain(scc.getDomain());
}
} else {
cookie.setDomain(context.getSessionCookieDomain());
}
// Always set secure if the request is secure
if (scc.isSecure() || secure) {
cookie.setSecure(true);
}
// Always set httpOnly if the context is configured for that
if (scc.isHttpOnly() || context.getUseHttpOnly()) {
cookie.setHttpOnly(true);
}
if (context.getUsePartitioned()) {
cookie.setAttribute(Constants.COOKIE_PARTITIONED_ATTR, "");
}
cookie.setPath(SessionConfig.getSessionCookiePath(context));
java/org/apache/catalina/core/ApplicationSessionCookieConfig.java lines 208–236
This is how it connects to what you see in configuration files.
| Cookie attribute | Where it is set (earlier wins) | Default |
|---|---|---|
| Name | <Context sessionCookieName> → web.xml <cookie-config><name> |
JSESSIONID |
Max-Age |
web.xml <cookie-config><max-age> |
-1 (a cookie that disappears when the browser closes, so it isn't in the example output) |
Secure |
web.xml <secure>, or always when the request is HTTPS |
absent over HTTP |
HttpOnly |
web.xml <http-only> or <Context useHttpOnly> |
present (useHttpOnly defaults to true, StandardContext.java line 649) |
Path |
<Context sessionCookiePath> → web.xml <path> → the web app path |
the web app path such as /u11; / for ROOT (SessionConfig.java lines 82–113) |
Note that HttpOnly is an "or". Turning it off in web.xml with <http-only>false</http-only> still leaves it on while <Context useHttpOnly> is at its default true; you have to turn off the Context side too. The SameSite attribute is not set here but by <CookieProcessor sameSiteCookies="…">, whose default is unset (not added) (tomcat/util/http/CookieProcessorBase.java line 56).
4. Expiry — a session dies in two places
isValid() actually carries out the expiry
The expiry check lives in StandardSession.isValid(). The name sounds like "is it valid?", but in fact it removes the session on the spot if its time is up.
@Override
public boolean isValid() {
if (!this.isValid) {
return false;
}
if (this.expiring) {
return true;
}
if (activityCheck && accessCount.get() > 0) {
return true;
}
if (maxInactiveInterval > 0) {
int timeIdle = (int) (getIdleTimeInternal() / 1000L);
if (timeIdle >= maxInactiveInterval) {
expire(true);
}
}
return this.isValid;
}
java/org/apache/catalina/session/StandardSession.java lines 476–499
- Idle time (
getIdleTimeInternal()) is "now − last access time" (lines 410–419). The last access time is updated inaccess()(when a request fetches the session) andendAccess()(when the request ends) (lines 522–553).endAccess()is called fromRequest.recycleSessionInfo(), which runs when the request object is recycled (Request.javalines 573–587). That is also whereisNewturnsfalse(line 536). - If
maxInactiveIntervalis 0 or less, the session never expires by time. That is whysession.setMaxInactiveInterval(-1)means "forever". activityCheckmeans "don't expire a session while a request is using it". Its default follows the JVM optionorg.apache.catalina.STRICT_SERVLET_COMPLIANCE, so it is normally off (ManagerBase.javalines 208–210), and you can also turn it on directly with<Manager sessionActivityCheck="true">.
So who calls isValid()? Two places.
The gray boxes are the starting points. Whichever way you come, it ends in a single expire().
① When a request arrives. doGetSession() from section 2 calls isValid() right after findSession(). In effect, a request carrying the ID of an expired session is what removes it.
② The housekeeping thread. A session nobody asks for again would never be removed by ①. So all sessions are scanned periodically. It starts in ContainerBase's background task.
backgroundProcessorFuture = Container.getService(this).getServer().getUtilityExecutor()
.scheduleWithFixedDelay(new ContainerBackgroundProcessor(), backgroundProcessorDelay,
backgroundProcessorDelay, TimeUnit.SECONDS);
java/org/apache/catalina/core/ContainerBase.java lines 1090–1092
Only containers with backgroundProcessorDelay greater than 0 schedule this task (line 1079). The default is -1 (line 142), and only StandardEngine changes it to 10 in its constructor (StandardEngine.java lines 59–63). So with default settings, one Engine, every 10 seconds, walks down its children (Host → Context → Wrapper) calling backgroundProcess() (processChildren(), lines 1164–1185). The work runs on the Server's utility thread pool (Catalina-utility-N). StandardContext.backgroundProcess() asks the class loader, the Manager and the resources to do their housekeeping in that order (StandardContext.java lines 4961–5001), and the Manager side looks like this.
@Override
public void backgroundProcess() {
count = (count + 1) % processExpiresFrequency;
if (count == 0) {
processExpires();
}
}
java/org/apache/catalina/session/ManagerBase.java lines 597–603
processExpiresFrequency defaults to 6 (line 181). 10 seconds × 6 = processExpires() runs once every 60 seconds, and all it does is call isValid() on every session (lines 608–629). The removing is done by the expire() inside isValid().
③ invalidate(). This is the web app ending the session itself, as on logout. StandardSession.invalidate() throws IllegalStateException if the session is already invalid, and otherwise calls expire() (lines 904–914).
expire() — the order of removal
// Mark this session as "being expired"
expiring = true;
// ... call each HttpSessionListener's sessionDestroyed() in reverse registration order ...
// Remove this session from our manager's active sessions
manager.remove(this, true);
// ... notify SessionListeners (Tomcat-internal), and logout() if a user is logged in ...
String[] keys;
synchronized (attributes) {
// We have completed expire of this session
setValid(false);
expiring = false;
// Snapshot the attributes before permitting any racing setAttribute() call to observe the invalid state
keys = keys();
}
Lines 600–601, 646–647 and 663–671 of java/org/apache/catalina/session/StandardSession.java lines 578–685
expiring = truemarks the session "being removed". This stops a listener that callsisValid()orgetAttribute()from re-enteringexpire(). Theif (this.expiring) return true;inisValid()is its counterpart.HttpSessionListener.sessionDestroyed()is called in reverse registration order (lines 614–636,j = (listeners.length - 1) - i). The session is still alive at this point, so listeners can read its attributes. The call is made with the web app's class loader switched in (line 613,context.bind(null)), because the housekeeping thread is not a web app thread.- The session is taken out of the map (
manager.remove(this, true);truemeans "removed by expiry, so count it in the statistics"). isValidbecomesfalse, and attributes are removed one by one (lines 673–682). If an attribute value is anHttpSessionBindingListener, itsvalueUnbound()is called now.
Example 2: seeing the three paths by thread name
We wrote an HttpSessionListener that prints, when a session is created or destroyed, the name of the thread that did it plus the Tomcat side of the call stack (ex/u11-expire).
// An example written for this post (not part of Tomcat's source)
// ... package and imports omitted ...
@WebListener
public class SessionLog implements HttpSessionListener {
// ... sessionCreated() prints only the first 8 characters of the ID and the thread name ...
@Override
public void sessionDestroyed(HttpSessionEvent e) {
String thread = Thread.currentThread().getName();
System.out.println("[u11] " + app(e) + " destroyed " + e.getSession().getId().substring(0, 8)
+ " on " + thread);
// Who removed it — print at most the top 7 lines of the Tomcat call stack
int shown = 0;
for (StackTraceElement f : new Throwable().getStackTrace()) {
if (f.getClassName().startsWith("org.apache.catalina") && shown++ < 7) {
System.out.println("[u11] at " + f.getClassName() + "." + f.getMethodName());
}
}
}
}
[Shortened from the example ex/u11-expire/src/ex/u11/SessionLog.java]
The servlet /touch fetches the session; with ?ttl=2 it makes a 2-second session via setMaxInactiveInterval(2), and with ?invalidate it calls invalidate(). We deployed the same web app under two names.
/lazy— default settings. Housekeeping runs once a minute, so it never runs while the example is going./bg—META-INF/context.xmlmakes housekeeping run every second.
<Context backgroundProcessorDelay="1">
<Manager processExpiresFrequency="1" />
</Context>
Giving a Context its own backgroundProcessorDelay makes that Context schedule its own background task, and the Engine's task skips it (the child.getBackgroundProcessorDelay() <= 0 check on line 1182 of processChildren()). The result:
===== /lazy: create a session with a 2 s timeout =====
session E7009DE4 isNew=true ttl=2s
/lazy created E7009DE4 on http-nio-8080-exec-10
===== /lazy: wait 4 s, then send the old cookie =====
session 97FDB75E isNew=true ttl=1800s
/lazy destroyed E7009DE4 on http-nio-8080-exec-2
at org.apache.catalina.session.StandardSession.expire
at org.apache.catalina.session.StandardSession.isValid
at org.apache.catalina.connector.Request.doGetSession
at org.apache.catalina.connector.Request.getSessionInternal
at org.apache.catalina.authenticator.AuthenticatorBase.invoke
at org.apache.catalina.core.StandardHostValve.invoke
at org.apache.catalina.valves.ErrorReportValve.invoke
/lazy created 97FDB75E on http-nio-8080-exec-2
===== /bg: create a session with a 2 s timeout, then send nothing for 4 s =====
session 28C1B8F5 isNew=true ttl=2s
/bg created 28C1B8F5 on http-nio-8080-exec-3
/bg destroyed 28C1B8F5 on Catalina-utility-1
at org.apache.catalina.session.StandardSession.expire
at org.apache.catalina.session.StandardSession.isValid
at org.apache.catalina.session.ManagerBase.processExpires
at org.apache.catalina.session.ManagerBase.backgroundProcess
at org.apache.catalina.core.StandardContext.backgroundProcess
at org.apache.catalina.core.ContainerBase$ContainerBackgroundProcessor.processChildren
at org.apache.catalina.core.ContainerBase$ContainerBackgroundProcessor.run
===== /lazy: invalidate() =====
/lazy created 4CF22D6A on http-nio-8080-exec-5
session 4CF22D6A isNew=false ttl=1800s
invalidated
/lazy destroyed 4CF22D6A on http-nio-8080-exec-7
at org.apache.catalina.session.StandardSession.expire
at org.apache.catalina.session.StandardSession.expire
at org.apache.catalina.session.StandardSession.invalidate
at org.apache.catalina.session.StandardSessionFacade.invalidate
at org.apache.catalina.core.ApplicationFilterChain.doFilter
at org.apache.catalina.core.ApplicationFilterChain.doFilter
at org.apache.catalina.core.StandardWrapperValve.invoke
Session IDs and thread numbers differ on every run. The stacks keep only lines starting with org.apache.catalina, so the servlet (ex.u11) and jakarta.servlet frames are missing.
- ①
/lazy: we made a 2-second session and sent the old cookie 4 seconds later. The request thread (http-nio-8080-exec-2) removed it viadoGetSession()→isValid()→expire(), and on the same thread immediately created a new session (97FDB75E). The client gets a new session withisNew=trueand no error. - ②
/bg: no request was sent, yet theCatalina-utility-1thread removed it. The stack matches the right-hand path in the diagram:ContainerBackgroundProcessor.run()→StandardContext.backgroundProcess()→ManagerBase.backgroundProcess()→processExpires()→isValid()→expire(). - ③
invalidate(): the request thread removed it inside the servlet call.expireappears twice becauseexpire()callsexpire(true).
Easy to get confused
- There is no servlet in the stack for ①. The first thing to touch the session was not the servlet's
getSession()butAuthenticatorBase.invoke(). Even a web app with no login configuration gets aNonLoginAuthenticator("no authentication") valve fromContextConfig(startup/ContextConfig.javalines 339–403), and that valve callsgetSessionInternal(false)to see whether a logged-in user is stored in the session (authenticator/AuthenticatorBase.javalines 504–521). So an expired session is already removed at the valve stage, before the servlet is reached. Valves are covered in part 8, authenticators in part 14. - A session does not vanish the moment
session-timeoutpasses. A session nobody asks for stays in memory until the nextprocessExpires()— up to about 60 seconds more with default settings. Don't expectsessionDestroyed()to fire at exactly 30 minutes. On the other hand, if a request comes in with that session's ID, ① removes it right away, so an expired session is never used again. sessionDestroyed()may not run on a request thread. As in ②, it can run on a utility thread, so a listener must not rely on aThreadLocalor look for request information.
5. Restarts and sessions — StandardManager's saving
By default, nothing is saved
StandardManager can write sessions to a file when Tomcat stops and read them back when it starts. But pathname, which names the file, defaults to null.
protected String pathname = null;
java/org/apache/catalina/session/StandardManager.java line 76
With an empty pathname, file() returns null (lines 331–334), and load() and unload() return without doing anything. The conf/context.xml that ships with Tomcat has this setting only as a comment.
<!-- Uncomment this to enable session persistence across Tomcat restarts -->
<!--
<Manager pathname="SESSIONS.ser" />
-->
conf/context.xml holds the default <Context> settings applied to every web app (part 10). Only after you uncomment this do sessions survive a restart. A relative path is resolved under the web app's temporary work folder (work/Catalina/<host>/<webapp>/) (lines 335–343).
The red path is the default. A restart logs every user out.
unload() and load()
Here is the core of stopping (stopInternal() → unload()).
oos.writeObject(Integer.valueOf(sessions.size()));
for (Session s : sessions.values()) {
StandardSession session = (StandardSession) s;
list.add(session);
session.passivate();
session.writeObjectData(oos);
}
}
}
// Expire all the sessions we just wrote
if (log.isDebugEnabled()) {
log.debug(sm.getString("standardManager.expiringSessions", Integer.toString(list.size())));
}
for (StandardSession session : list) {
try {
session.expire(false);
} catch (Throwable t) {
ExceptionUtils.handleThrowable(t);
} finally {
session.recycle();
}
}
java/org/apache/catalina/session/StandardManager.java lines 225–247
It writes the number of sessions first, then for each session calls passivate() (telling attributes that are HttpSessionActivationListeners via sessionWillPassivate()) and serializes it. Written sessions are cleaned up with expire(false), where false means don't call sessionDestroyed() — the session isn't over, it is just moving to disk for a while. With the default no-save settings, the later part of stopInternal() simply calls expire() on the remaining sessions (lines 303–317), so sessionDestroyed() is called.
During serialization, attributes are filtered like this.
// Accumulate the names of serializable and non-serializable attributes
String[] keys = keys();
List<String> saveNames = new ArrayList<>();
List<Object> saveValues = new ArrayList<>();
for (String key : keys) {
Object value = attributes.get(key);
if (value == null) {
// Continue
} else if (isAttributeDistributable(key, value) && !exclude(key, value)) {
saveNames.add(key);
saveValues.add(value);
} else {
removeAttributeInternal(key, true);
}
}
java/org/apache/catalina/session/StandardSession.java lines 1341–1355
isAttributeDistributable() is value instanceof Serializable (lines 1134–1136). Attributes that are not Serializable are silently removed, with no error.
Starting (startInternal() → load()) is the reverse. It reads the count, revives each session with readObjectData(), puts it in the map and calls activate() (the sessionDidActivate() notification), and then deletes the file whether that succeeded or not (lines 153–178). Sessions that were already invalid when saved are skipped (lines 157–166). The Manager starts after the web app's listeners have started (StandardContext.java lines 4616–4636), so listeners can receive the notifications of revived sessions. When stopping, the order is reversed and the Manager stops before the listeners (lines 4834–4840).
Example 3: stop and start
The servlet /count puts three things in the session: count, a number that can be serialized; plain, an object that is not Serializable; and witness, which implements HttpSessionActivationListener (ex/u11-restart).
// An example written for this post (not part of Tomcat's source)
// ... package and imports omitted. Inside @WebServlet("/count") public class CountServlet extends HttpServlet { ...
/** Not Serializable — it can't be saved to disk */
static class NotSerializable {
}
/** Gets notified when the session goes out to disk (passivate) and comes back (activate) */
static class Witness implements HttpSessionActivationListener, Serializable {
@Override
public void sessionWillPassivate(HttpSessionEvent e) {
System.out.println("[u11] sessionWillPassivate " + e.getSession().getId().substring(0, 8));
}
@Override
public void sessionDidActivate(HttpSessionEvent e) {
System.out.println("[u11] sessionDidActivate " + e.getSession().getId().substring(0, 8));
}
}
// ... doGet(): on the first call put plain and witness, then bump count and print it ...
[Shortened from the example ex/u11-restart/src/ex/u11/CountServlet.java]
With the default settings (default) and with the comment in conf/context.xml removed (persist), we did: two requests → catalina.sh stop → catalina.sh start → one request with the same cookie.
===== [default] two requests =====
session BF6AC4DB isNew=true count=1 attributes=[witness, plain, count]
session BF6AC4DB isNew=false count=2 attributes=[witness, plain, count]
===== [default] stop Tomcat =====
no SESSIONS.ser
===== [default] start again, same cookie =====
session C928A7E4 isNew=true count=1 attributes=[witness, plain, count]
no SESSIONS.ser
===== [persist] conf/context.xml =====
29: <Manager pathname="SESSIONS.ser" />
===== [persist] two requests =====
session 67837EDF isNew=true count=1 attributes=[witness, plain, count]
session 67837EDF isNew=false count=2 attributes=[witness, plain, count]
===== [persist] stop Tomcat =====
sessionWillPassivate 67837EDF
336 bytes /tmp/base/work/Catalina/localhost/u11/SESSIONS.ser
===== [persist] start again, same cookie =====
sessionDidActivate 67837EDF
session 67837EDF isNew=false count=3 attributes=[witness, count]
no SESSIONS.ser
Session IDs differ on every run, and the order of attribute names is the map's order, which may vary.
- default: stopping creates no file, and after restarting, the same cookie gets a new session (
count=1). - persist: on stop,
sessionWillPassivatewas called andwork/Catalina/localhost/u11/SESSIONS.ser(336 bytes) appeared. On start,sessionDidActivatewas called, and the session with the same ID continued atcount=3. The file was deleted after reading. plainis gone. It wasn'tSerializable, so it was removed while saving, and the log showed no warning at all.
Easy to get confused
- It doesn't help against
kill -9or a power cut. The file is written only instopInternal(), i.e. when Tomcat stops normally. If it dies suddenly, the sessions are lost. To back them up to disk while running, use thePersistentManagerin the next section. - Make the objects you put in a session
Serializable. As soon as you turn on restart saving or cluster replication, attributes that aren't will silently drop out. If the web app is<distributable/>,setAttribute()tells you immediately with anIllegalArgumentException(StandardSession.javalines 1000–1002). - The same thing happens on a web app reload. A reload (part 4) stops and restarts the
Context, so without apathname, sessions disappear on every reload.
A taste of PersistentManager
<Manager className="org.apache.catalina.session.PersistentManager"> goes one step further than StandardManager. It keeps sessions in a <Store> (FileStore for files, DataSourceStore for a database), and when a request comes with an ID that is not in memory, it brings the session back from the store (swapIn()).
@Override
public Session findSession(String id) throws IOException {
Session session = super.findSession(id);
// ... another thread may be swapping it out, so lock the session and look it up in the map again ...
if (session != null) {
return session;
}
// See if the Session is in the Store
session = swapIn(id);
return session;
}
java/org/apache/catalina/session/PersistentManagerBase.java lines 370–398
During housekeeping, processPersistenceChecks() runs after the expiry check and moves long-idle sessions out to the store (maxIdleSwap), moves sessions out when there are too many (maxActiveSessions), or just backs them up (maxIdleBackup) (processExpires() lines 322–348, processPersistenceChecks() lines 355–361). Turning on maxIdleSwap or minIdleSwap forces on the activityCheck we saw in section 4 (lines 292–294), so that a session a request is currently using is not swapped out to disk (lines 793–796). We did not run this one as an example for this post.
What this post leaves out
- Changing the session ID (
changeSessionId(), androtateSessionId()used at login to prevent session fixation):Request.javalines 2341–2375,ManagerBase.javalines 801–828. Where authenticators call it is in part 14. - Re-mapping by session ID to pick an older web app version under parallel deployment (
app##2):CoyoteAdapter.javalines 745–780 - The
SSLtracking mode, which uses the TLS session ID as the session ID:CoyoteAdapter.parseSessionSslId()from line 985 - Cluster session replication (
DeltaManager,BackupManager):java/org/apache/catalina/ha/session/ - The store implementations
FileStoreandDataSourceStore, and their common baseStoreBase:java/org/apache/catalina/session/ - Session attribute name and class filters (
sessionAttributeNameFilter,sessionAttributeValueClassNameFilter):ManagerBase.javalines 841–875 - Statistics (
sessionCreationTiming, expired count, average lifetime) and their JMX exposure: around line 1020 ofManagerBase, andsession/mbeans-descriptors.xml. JMX is covered in part 16.
Summary
| Concept | One-line summary |
|---|---|
StandardManager |
One per web app. If you don't write <Manager>, StandardContext makes one |
| Session storage | ManagerBase.sessions — a single ConcurrentHashMap keyed by ID |
| Reading the ID | CoyoteAdapter.postParseRequest() reads ;jsessionid= and then the cookie into requestedSessionId. The cookie wins |
doGetSession() |
Find it (findSession + isValid), or make one. The client's ID is almost never reused |
| Session ID | 16 bytes from a SecureRandom pool → 32 upper-case hex characters, plus .route if jvmRoute is set. No duplicate check |
| Cookie attributes | Path is the web app path, HttpOnly is on by default, Secure over HTTPS |
| URL rewriting | encodeURL() adds ;jsessionid= unless the request's ID came in a cookie — even on the first response |
| Expiry check | isValid() looks at idle time and calls expire() on the spot |
| When expiry happens | When a request arrives with that ID, or on the housekeeping thread (every 10 s × 6 = 60 s by default) |
expire() |
Listeners (in reverse) → removed from the map → isValid=false → attributes removed |
| Saving across restarts | Off by default. Turn it on with <Manager pathname="SESSIONS.ser"/>; only on a clean stop, only Serializable attributes |
What's next
With sessions, we have seen almost everything Tomcat does for a request while a servlet handles it. So far, one thread has held on to a request from start to finish. Could a servlet let go of that thread while it waits for something slow? Part 12: Async Servlets and Non-Blocking I/O — Letting Go of the Thread follows startAsync() and the AsyncStateMachine.
httpd touches sessions too. Sticky sessions, which httpd part 14 only named at the end, are mod_proxy_balancer reading the part after . in a cookie value such as JSESSIONID as a route and sending the request to the same backend (find_session_route()). Its counterpart is StandardSessionIdGenerator appending .jvmRoute to the ID, as we saw in section 3. Put httpd in front of several Tomcats, and httpd picks the way by reading the tail of the session ID that Tomcat made. Meanwhile, the Basic authentication in httpd part 13 receives the user name and password again in the Authorization header on every request, so the server needs no session. Tomcat's FORM login does the opposite: it keeps the logged-in user in the session from this post, and the AuthenticatorBase we met in section 4 takes it out of the session on each request. The details are in part 14.