No Session? No Problem: Designing Cloud-Native Java Apps Without HttpSession in Quarkus
Why Quarkus avoids traditional servlet sessions and how to build scalable, stateless, cloud-ready applications the modern way.
Developers who migrate from classic Jakarta EE or Spring MVC often look for HttpSession in Quarkus and find nothing.
That’s not an accident. It’s a deliberate architectural decision.
In the servlet era, HttpSession was a practical shortcut for per-user state management. You could store login details, preferences, or shopping carts directly in the server memory. It felt easy—until cloud-native computing changed the rules.
In distributed systems, this approach collapses under its own weight.
When your application runs on Kubernetes or OpenShift, a new request might be handled by any pod in the cluster. If session data lives in memory, it disappears with the pod. You can replicate sessions, but that introduces complexity and latency.
And since Quarkus builds on Vert.x and reactive I/O instead of the servlet API, the traditional HttpSession model doesn’t even exist here.
The result is intentional statelessness. Every request in Quarkus is self-contained. No hidden memory state, no fragile replication layer, no session leaks.
The modern alternative: explicit state and external stores
Modern cloud-native applications pass context explicitly instead of relying on server memory. That means authentication tokens, user identifiers, and transient data must be carried with each request or stored externally.
A typical design looks like this: the client holds a signed token such as a JWT. The token identifies the user and can include claims like roles or expiration timestamps. For temporary data, such as form progress or shopping carts, the server uses an external cache like Redis. Long-lived data belongs in a database or object store.
This architecture decouples user data from the application’s lifecycle. You can restart or scale the service at will. Each request contains enough information to process it independently.
Stateless identity with JWT
Let’s build a small Quarkus API that authenticates users and returns a greeting without ever touching a session.
Project setup
Let’s use the Quarkus CLI to create a new application quickly:
quarkus create app org.acme:stateless-api \
--extension="smallrye-jwt, rest-jackson"
cd stateless-apiConfiguration
Next, you’ll need to tweak some settings in the application.properties file. If you do not have the pem files handy, take a look at my older JWT article and generate one quickly.
smallrye.jwt.sign.key.location=privateKey.pem
mp.jwt.verify.publickey.location=publicKey.pem
mp.jwt.verify.issuer=https://the-main-thread.com
quarkus.http.auth.permission.authenticated.paths=/hello
quarkus.http.auth.permission.authenticated.policy=authenticatedToken endpoint
package org.acme;
import java.time.Instant;
import java.util.Set;
import io.smallrye.jwt.build.Jwt;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path(”/token”)
@Produces(MediaType.TEXT_PLAIN)
public class TokenResource {
@GET
public String issueToken() {
return Jwt.issuer(”https://the-main-thread.com”)
.upn(”duke@example.com”)
.groups(Set.of(”user”))
.expiresAt(Instant.now().plusSeconds(3600))
.sign();
}
}This endpoint generates a sample JSON Web Token (JWT) directly from within the application.
It’s a simple demonstration of how Quarkus uses the SmallRye JWT API to create a signed token containing basic user information. The token includes an issuer, a user principal name (upn), an expiration time, and a group claim that maps to a user role.
When you hit /token, the app signs the payload with the private key configured in application.properties, returning a string token. You can then include that token in an Authorization header when calling /hello.
It’s essentially a self-contained login shortcut for testing authentication flow — not a real identity provider.
In production, this endpoint should be replaced entirely. You normally don’t want your application to mint its own tokens. Instead, tokens should come from a trusted identity provider (IdP) or OpenID Connect (OIDC) server such as:
Keycloak (open source, integrated with Quarkus OIDC)
Red Hat SSO or IBM Security Verify
Auth0, Azure AD, Google Identity, or any other OIDC-compliant provider
Your Quarkus app should then validate, not create, tokens.
The smallrye-jwt extension used above verifies signatures, issuers, and claims but doesn’t handle user authentication itself. The Quarkus OIDC extension adds that layer.
Protected endpoint
Replace the content of the GreetingResource with the following:
package org.acme;
import org.eclipse.microprofile.jwt.JsonWebToken;
import jakarta.annotation.security.RolesAllowed;
import jakarta.inject.Inject;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.core.MediaType;
@Path(”/hello”)
@Produces(MediaType.APPLICATION_JSON)
public class GreetingResource {
@Inject
JsonWebToken jwt;
@GET
@RolesAllowed(”user”)
public String hello() {
return “{\”message\”:\”Hello “ + jwt.getName() + “!\”}”;
}
}
Run the app:
quarkus devThen issue and use a token:
TOKEN=$(curl -s http://localhost:8080/token)
curl -H "Authorization: Bearer $TOKEN" http://localhost:8080/helloExpected output:
{
“message”: “Hello duke@example.com!”
}No sessions, no sticky routing. Just secure, stateless communication.
Keeping transient state with Redis and headers
Even stateless systems sometimes need short-lived data.
Imagine a cart, a form draft, or a temporary workflow.
In the servlet world, that would live in the HttpSession. In Quarkus, you push it out to an external store and key it by an identifier that travels with each request.
The simplest way to do this is via an HTTP header like X-User-Id. Let’s wire it up with Redis.
Add Redis support
quarkus extension add quarkus-redis-clientQuarkus will automatically launch a Redis container during development.
Create the REST resource
package org.acme;
import io.quarkus.redis.datasource.RedisDataSource;
import io.quarkus.redis.datasource.value.ValueCommands;
import jakarta.annotation.PostConstruct;
import jakarta.inject.Inject;
import jakarta.ws.rs.Consumes;
import jakarta.ws.rs.GET;
import jakarta.ws.rs.HeaderParam;
import jakarta.ws.rs.POST;
import jakarta.ws.rs.Path;
import jakarta.ws.rs.Produces;
import jakarta.ws.rs.WebApplicationException;
import jakarta.ws.rs.core.MediaType;
import jakarta.ws.rs.core.Response;
@Path(”/cart”)
@Produces(MediaType.APPLICATION_JSON)
@Consumes(MediaType.APPLICATION_JSON)
public class CartResource {
@Inject
RedisDataSource redis;
private ValueCommands<String, String> commands;
@PostConstruct
void init() {
commands = redis.value(String.class);
}
@POST
public Response addItem(@HeaderParam(”X-User-Id”) String userId, String item) {
if (userId == null || userId.isBlank()) {
throw new WebApplicationException(”Missing X-User-Id header”, 400);
}
commands.set(userId + “:cart”, item);
return Response.ok(”{\”status\”:\”added\”}”).build();
}
@GET
public Response getItem(@HeaderParam(”X-User-Id”) String userId) {
if (userId == null || userId.isBlank()) {
throw new WebApplicationException(”Missing X-User-Id header”, 400);
}
String value = commands.get(userId + “:cart”);
if (value == null) {
return Response.ok(”{\”cart\”: []}”).build();
}
return Response.ok(”{\”cart\”: [\”“ + value + “\”]}”).build();
}
}Start the app in dev mode and test it:
curl -X POST -H "X-User-Id: duke42" -H "Content-Type: application/json" -d 'Coffee Mug' http://localhost:8080/cart
curl -H "X-User-Id: duke42" http://localhost:8080/cartYou’ll see:
{"status":"added"}
{"cart": ["Coffee Mug"]}Containerizing the stateless app
To run multiple instances, you need a container image first.
Quarkus simplifies that with built-in container-image support.
Add the container-image extension
quarkus extension add quarkus-container-image-jibBuild with Podman
And don’t forget to delete the tests before you run this ;)
./mvnw clean package -Dquarkus.container-image.build=trueYou’ll get an image called localhost/<USERNAME>/stateless-api:1.0.0-SNAPSHOT.
Verify it:
podman images | grep stateless-apiRun and scale with Podman Compose
To simulate a small cluster, define a podman-compose.yml:
services:
quarkus-app:
image: localhost/meisele/stateless-api:1.0.0-SNAPSHOT
# No port mapping - containers communicate internally
environment:
- QUARKUS_REDIS_HOSTS=redis://redis:6379
depends_on:
- redis
redis:
image: redis:7
nginx:
image: nginx:alpine
ports:
- “8080:80”
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf:ro
depends_on:
- quarkus-appNote, we also added a small Ngnix locally. Add the conf file or grab it from my repository:
events {
worker_connections 1024;
}
http {
upstream quarkus_backend {
server quarkus-app:8080;
}
server {
listen 80;
location / {
proxy_pass http://quarkus_backend;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}Scale horizontally and start the Quarkus app three times:
podman compose up --scale quarkus-app=3Each Quarkus container connects to the same Redis instance.
After building the container and launching multiple instances, make repeated API calls. Every response is identical, no matter which container responds. You can even simulate failures, stop or restart pods, and the data remains accessible through Redis.
curl -X POST -H "X-User-Id: duke42" -H "Content-Type: application/json" -d 'Coffee Mug' http://localhost:8080/cart
curl -H "X-User-Id: duke42" http://localhost:8080/cartNo matter which backend is hit, you always get the same cart item back that you placed in to under the user-id “duke42”. You can take a look at the log files and also monitor the logs:
podman compose logs quarkus-appThat’s the essence of cloud-native behavior: your application scales horizontally, survives restarts, and treats all nodes as stateless workers.
If you want to test the load-balancing further, feel tree to test out the ./test-load-balancing.sh in the github repository.
Stop and remove all container:
podman stop --all && podman rm --all --forceProduction and security considerations
Tokens should always have a limited lifetime, and Redis entries should expire after a short period. You can use setex() to store items with a time-to-live:
commands.setex(userId + “:cart”, 600, item);Rotate JWT signing keys regularly, and externalize any sensitive configuration such as Redis credentials.
In production, use a managed Redis cluster or in-memory grid such as Infinispan, and disable sticky sessions at the load balancer level.
Each instance should be replaceable at any time.
Migrating from HttpSession to cloud-native state management
Teams moving from servlet-based frameworks often fear losing convenience. The trick is to shift your mindset: from implicit to explicit, from in-memory to distributed.
Start by identifying where your old application relied on HttpSession. Each session.setAttribute() call signals hidden coupling between requests. Replace these with explicit tokens or persistent store calls. For example, if you stored a user’s preferences in the session, serialize them to a small Redis entry or a database row keyed by user ID.
Authentication flows change too. Instead of a session ID managed by the container, issue JWTs on login and verify them for every request. This not only removes state but also allows cross-service single sign-on through OpenID Connect or OAuth2.
Filters and interceptors that depended on the servlet session should now look at headers or tokens instead. Context information, like roles, locale, or feature flags, travels with each request. The code becomes more predictable and testable.
Think of this migration as a gradual untangling. You’re not losing features; you’re making state explicit. Once you decouple state from memory, you gain the ability to redeploy, scale, or roll back without disconnecting users.
The resulting system is simpler to operate and inherently more resilient.
Closing thoughts
HttpSession made sense when applications lived on a single server. In a modern distributed world, it’s a liability. Quarkus omits it on purpose, freeing developers from the constraints of memory-bound state.
By externalizing data through headers, tokens, and caches, your architecture becomes more predictable, scalable, and maintainable.
Statelessness isn’t a limitation: It’s a discipline.
State belongs where it can be shared, persisted, and recovered and not in a servlet’s memory.



