Repository: jetty/jetty.project (2026-03-11T19:05:47Z)

Issue:
Retain negative Max-Age cookie attribute
**Jetty version(s)**
12.1.x

**Description**
Currently, a negative `Max-Age` attribute is parsed by `RFC6265SetCookieParser`, then passed to `HttpCookie.Builder.attribute()`, then to `HttpCookie.Builder.maxAge()`, that removes the attribute from the attribute map.

This is mostly transparent, except when the `Max-Age` attribute is negative and not `-1`:

```
Set-Cookie: A=B; max-age=-2

cookie.getMaxAge() == -1;
cookie.getAttributes().size() == 0;
```

There is value in being able to distinguish the case "no Max-Age attribute" from "Max-Age attribute present with negative value".

About interpreting negative `Max-Age` values, RFC 6265 states that the cookie should be considered expired, but browsers, Java's HttpClient and HttpCookie classes, and Jakarta's Cookie class all agree to consider it a "session cookie" that is not expired, but won't be persisted, and will be removed as the browser or client is closed or stopped.


Discussion:

Patch:
diff --git a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/HttpCookie.java b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/HttpCookie.java
index 52081b0468c..d90c3ec9ae0 100644
--- a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/HttpCookie.java
+++ b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/HttpCookie.java
@@ -561,7 +561,7 @@ public Builder attribute(String name, String value)
             if (name == null)
                 return this;
             // Sanity checks on the values, expensive but necessary to avoid to store garbage.
-            switch (name.toLowerCase(Locale.ENGLISH))
+            switch (name.toLowerCase(Locale.ROOT))
             {
                 case "expires" -> expires(StringUtil.isBlank(value) ? null : parseExpires(value));
                 case "httponly" -> httpOnly(asBoolean("httponly", value));
@@ -608,10 +608,7 @@ public Builder httpOnly(boolean httpOnly)
 
         public Builder maxAge(long maxAge)
         {
-            if (maxAge >= 0)
-                _attributes = lazyAttributePut(_attributes, MAX_AGE_ATTRIBUTE, Long.toString(maxAge));
-            else
-                _attributes = lazyAttributeRemove(_attributes, MAX_AGE_ATTRIBUTE);
+            _attributes = lazyAttributePut(_attributes, MAX_AGE_ATTRIBUTE, Long.toString(maxAge));
             return this;
         }
 
@@ -834,7 +831,7 @@ static int hashCode(HttpCookie httpCookie)
     {
         String domain = httpCookie.getDomain();
         if (domain != null)
-            domain = domain.toLowerCase(Locale.ENGLISH);
+            domain = domain.toLowerCase(Locale.ROOT);
         return Objects.hash(httpCookie.getName(), domain, httpCookie.getPath());
     }
 
diff --git a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/HttpCookieStore.java b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/HttpCookieStore.java
index faa757dbb78..a6cd3b00ba1 100644
--- a/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/HttpCookieStore.java
+++ b/jetty-core/jetty-http/src/main/java/org/eclipse/jetty/http/HttpCookieStore.java
@@ -120,6 +120,16 @@ public boolean clear()
 
     /**
      * <p>A default implementation of {@link HttpCookieStore}.</p>
+     * <p>RFC 6265, section 5.2.2, states that negative values of the
+     * {@code Max-Age} attribute should be treated like `0`, which
+     * indicates that the cookie is expired.
+     * However, the de-facto standard for implementations (browsers
+     * and {@code java.net.http.HttpClient}) is that negative values
+     * are treated as "session cookie": a cookie that is not expired,
+     * but won't be persisted and will be removed when the browser
+     * or client is closed or stopped.
+     * This is in line with the javadocs of {@code java.net.HttpCookie}
+     * as well as {@code jakarta.servlet.http.Cookie}.
      */
     class Default implements HttpCookieStore
     {


Test patch:
diff --git a/jetty-core/jetty-http/src/test/java/org/eclipse/jetty/http/HttpCookieTest.java b/jetty-core/jetty-http/src/test/java/org/eclipse/jetty/http/HttpCookieTest.java
index 15102803691..bd257186546 100644
--- a/jetty-core/jetty-http/src/test/java/org/eclipse/jetty/http/HttpCookieTest.java
+++ b/jetty-core/jetty-http/src/test/java/org/eclipse/jetty/http/HttpCookieTest.java
@@ -55,6 +55,10 @@ public static List<Arguments> cookies()
             Arguments.of("A=B; a=", HttpCookie.build("A", "B").attribute("a", "").build()),
             Arguments.of("A=B; a=v", HttpCookie.build("A", "B").attribute("a", "v").build()),
             Arguments.of("A=B; Secure; Path=/", HttpCookie.build("A", "B").secure(true).path("/").build()),
+            Arguments.of("A=B; Max-Age=-2", HttpCookie.build("A", "B").maxAge(-2).build()),
+            Arguments.of("A=B; Max-Age=-1", HttpCookie.build("A", "B").maxAge(-1).build()),
+            Arguments.of("A=B; Max-Age=0", HttpCookie.build("A", "B").maxAge(0).build()),
+            Arguments.of("A=B; Max-Age=1", HttpCookie.build("A", "B").maxAge(1).build()),
             // Quoted cookie.
             Arguments.of("A=\"1\"", HttpCookie.build("A", "1").build()),
             Arguments.of("A=\"1\"; HttpOnly", HttpCookie.build("A", "1").httpOnly(true).build()),
@@ -157,4 +161,16 @@ public void testRemoveSpecialAttributes()
         assertThat(noAttributes.isSecure(), is(false));
         assertThat(noAttributes.isPartitioned(), is(false));
     }
+
+    @Test
+    public void testNegativeMaxAge()
+    {
+        HttpCookie cookie = HttpCookie.build("A", "B").maxAge(-1).build();
+        assertThat(cookie.getAttributes().size(), is(1));
+        assertThat(cookie.getMaxAge(), is(-1L));
+
+        cookie = HttpCookie.build("A", "B").attribute("max-age", "-2").build();
+        assertThat(cookie.getAttributes().size(), is(1));
+        assertThat(cookie.getMaxAge(), is(-2L));
+    }
 }
diff --git a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/HttpCookieTest.java b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/HttpCookieTest.java
index f06eb29de72..a0ec323c8ea 100644
--- a/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/HttpCookieTest.java
+++ b/jetty-core/jetty-server/src/test/java/org/eclipse/jetty/server/HttpCookieTest.java
@@ -295,7 +295,6 @@ public void testBuilderNull()
             .comment(null)
             .domain(null)
             .httpOnly(false)
-            .maxAge(-1)
             .secure(false)
             .path(null)
             .build();