Project: Login, Sessions & a Protected Area
Everything covered in this tutorial converges here. You will build a small but production-shaped application: a login form, a session-based authentication mechanism, a filter that guards every protected URL, and a clean logout. The goal is not just to make it work — it is to make it work the way a professional would: secure by default, easy to extend, and easy to reason about.
Project Overview
The finished application has four pieces:
- LoginServlet — renders the login form (GET) and processes credentials (POST).
- AuthFilter — a
jakarta.servlet.Filter mapped to /app/*; blocks unauthenticated access and redirects to the login page.
- DashboardServlet — a protected page, reachable only through the filter.
- LogoutServlet — invalidates the session and redirects to login.
No external framework. No Spring Security. Just the Servlet API — which is exactly what frameworks like Spring Security implement under the hood, so understanding this gives you real leverage.
Step 1 — The User Store
In a real application you query a database. For this project use a simple in-memory map so the focus stays on the authentication flow, not on JDBC plumbing.
package com.example.auth;
import java.util.Map;
public final class UserStore {
// username -> bcrypt hash (in production, use a real hasher)
private static final Map<String, String> USERS = Map.of(
"alice", "$2a$10$Examplehashfordemopurposesonly1",
"bob", "$2a$10$Examplehashfordemopurposesonly2"
);
private UserStore() {}
/** Returns true if credentials match. */
public static boolean verify(String username, String password) {
String stored = USERS.get(username);
if (stored == null) return false;
// replace with BCrypt.checkpw(password, stored) in production
return password.equals("demo"); // placeholder for the example
}
}
Never store plain-text passwords. In production always store a bcrypt (or Argon2) hash and use the matching library to verify. The jBCrypt library is a one-JAR dependency. This example uses a placeholder so the code compiles without extra dependencies.
Step 2 — The Login Servlet
The servlet handles both the form display (GET) and the credential check (POST). On success it writes the authenticated username into the session under a well-known key and redirects to the protected area. On failure it forwards back to the form with an error message.
package com.example.auth;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
@WebServlet("/login")
public class LoginServlet extends HttpServlet {
public static final String SESSION_USER_KEY = "authenticatedUser";
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
req.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(req, resp);
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String username = req.getParameter("username");
String password = req.getParameter("password");
if (username == null || password == null
|| !UserStore.verify(username, password)) {
req.setAttribute("error", "Invalid username or password.");
req.getRequestDispatcher("/WEB-INF/views/login.jsp").forward(req, resp);
return;
}
// Invalidate any pre-existing session to prevent session fixation
HttpSession oldSession = req.getSession(false);
if (oldSession != null) {
oldSession.invalidate();
}
// Create a fresh session and store the principal
HttpSession session = req.getSession(true);
session.setAttribute(SESSION_USER_KEY, username);
session.setMaxInactiveInterval(30 * 60); // 30-minute idle timeout
resp.sendRedirect(req.getContextPath() + "/app/dashboard");
}
}
Session fixation attack: An attacker can plant a known session ID in a victim's browser before login. If the application reuses that session after authentication, the attacker now holds a valid authenticated session. The fix is simple: always invalidate() the old session and create a fresh one at the moment of login. Never skip this step.
Step 3 — The Authentication Filter
The filter is the heart of the protected area. It intercepts every request matching /app/*. If the session contains the authenticated-user attribute the request proceeds; otherwise the user is sent to the login page. The original requested URL is saved so the user lands on the right page after logging in — a small but important UX detail.
package com.example.auth;
import jakarta.servlet.Filter;
import jakarta.servlet.FilterChain;
import jakarta.servlet.FilterConfig;
import jakarta.servlet.ServletException;
import jakarta.servlet.ServletRequest;
import jakarta.servlet.ServletResponse;
import jakarta.servlet.annotation.WebFilter;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
@WebFilter("/app/*")
public class AuthFilter implements Filter {
@Override
public void init(FilterConfig cfg) throws ServletException { /* nothing to initialise */ }
@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); // do NOT create a session here
boolean authenticated = session != null
&& session.getAttribute(LoginServlet.SESSION_USER_KEY) != null;
if (!authenticated) {
// Remember where the user was trying to go
String target = req.getRequestURI();
if (req.getQueryString() != null) {
target += "?" + req.getQueryString();
}
resp.sendRedirect(req.getContextPath() + "/login?next=" +
java.net.URLEncoder.encode(target, "UTF-8"));
return; // do NOT call chain.doFilter — request ends here
}
chain.doFilter(request, response); // authenticated: pass through
}
@Override
public void destroy() {}
}
Use getSession(false) in filters, never getSession(true). Passing true (or no argument) would create a new session for every unauthenticated visitor, wasting server memory. Pass false so that a missing session simply returns null — which is your unauthenticated signal.
Step 4 — The Dashboard Servlet
Because the filter already guarantees authentication, the servlet can focus entirely on its business logic. It trusts the session attribute and reads the username directly.
package com.example.auth;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import java.io.IOException;
@WebServlet("/app/dashboard")
public class DashboardServlet extends HttpServlet {
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
String user = (String) req.getSession().getAttribute(
LoginServlet.SESSION_USER_KEY);
req.setAttribute("username", user);
req.getRequestDispatcher("/WEB-INF/views/dashboard.jsp").forward(req, resp);
}
}
Step 5 — Logout
Logout must do two things: destroy the server-side session and remove the session cookie from the browser. Simply invalidating the session is enough to revoke server-side state; the cookie will just point to a non-existent session. Explicitly expiring the cookie as well is cleaner and avoids confusion in browser dev tools.
package com.example.auth;
import jakarta.servlet.ServletException;
import jakarta.servlet.annotation.WebServlet;
import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServlet;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.servlet.http.HttpSession;
import java.io.IOException;
@WebServlet("/logout")
public class LogoutServlet extends HttpServlet {
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp)
throws ServletException, IOException {
HttpSession session = req.getSession(false);
if (session != null) {
session.invalidate();
}
// Expire the JSESSIONID cookie in the browser
Cookie kill = new Cookie("JSESSIONID", "");
kill.setMaxAge(0);
kill.setPath(req.getContextPath().isEmpty() ? "/" : req.getContextPath());
kill.setHttpOnly(true);
resp.addCookie(kill);
resp.sendRedirect(req.getContextPath() + "/login");
}
}
Logout must use POST, not GET. A GET-based logout is vulnerable to CSRF: a malicious image tag on another site can silently log out your user. Use a form with method="post" and — if your application already has CSRF token infrastructure — include the token.
The JSP Views
Keep views thin. The login page reads the optional error attribute set by the servlet. The dashboard reads username. Both use the JSTL c:out tag to output values safely — it HTML-escapes automatically, preventing XSS.
<%-- login.jsp --%>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<form method="post" action="${pageContext.request.contextPath}/login">
<c:if test="${not empty error}">
<p class="error"><c:out value="${error}"/></p>
</c:if>
<input type="text" name="username" required />
<input type="password" name="password" required />
<button type="submit">Log in</button>
</form>
<%-- dashboard.jsp --%>
<%@ taglib prefix="c" uri="jakarta.tags.core" %>
<p>Welcome, <c:out value="${username}"/>!</p>
<form method="post" action="${pageContext.request.contextPath}/logout">
<button type="submit">Log out</button>
</form>
How the Pieces Work Together
Tracing a full login-to-dashboard round trip clarifies the role of each component:
- Browser GET
/app/dashboard — AuthFilter intercepts, finds no session, redirects to /login?next=/app/dashboard.
- Browser GET
/login — LoginServlet.doGet forwards to login.jsp.
- Browser POST
/login — LoginServlet.doPost verifies credentials, invalidates old session, creates new session, stores username, redirects to /app/dashboard.
- Browser GET
/app/dashboard — AuthFilter finds session attribute, calls chain.doFilter, DashboardServlet renders the page.
- Browser POST
/logout — LogoutServlet invalidates session, expires cookie, redirects to /login.
Security Hardening Checklist
- Session fixation: always invalidate before creating a post-login session. ✓
- HTTPS only: set the session cookie's
Secure flag in web.xml so JSESSIONID is never sent over plain HTTP.
- HttpOnly cookie: prevents JavaScript from reading the session cookie, blocking the most common XSS session-theft vector. Set in
web.xml with <cookie-config><http-only>true</http-only></cookie-config>.
- Short timeout:
setMaxInactiveInterval limits the damage if a session is stolen from an idle browser.
- CSRF on logout: use POST for all state-changing actions.
- Password hashing: never store or compare plain text. Use bcrypt or Argon2.
Summary
This project demonstrates the complete authentication loop using only the Servlet API. A Filter enforces access control declaratively by URL pattern, keeping authentication concerns out of business servlets. The HttpSession carries the authenticated principal across requests. Logout cleans up both server-side state and the browser cookie. Every security pitfall — session fixation, plain-text storage, GET-based logout, unescaped output — has a concrete, simple countermeasure. These patterns transfer directly to Spring Security, Jakarta Security, and any other framework you encounter: the underlying mechanics are the same.