Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
# 1.4.17 - TBD
Bug fix:
- [#1556](https://github.com/elysiajs/elysia/issues/1556) prevent sending set-cookie header when cookie value is not modified

# 1.4.16 - 13 Nov 2025
Improvement:
- ValidationError: add `messageValue` as an alias of `errorValue`
Expand Down
92 changes: 70 additions & 22 deletions src/cookies.ts
Original file line number Diff line number Diff line change
Expand Up @@ -142,7 +142,7 @@ export class Cookie<T> implements ElysiaCookie {
}

protected get setCookie() {
if (!(this.name in this.jar)) this.jar[this.name] = this.initial
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }

return this.jar[this.name]
}
Expand All @@ -156,87 +156,135 @@ export class Cookie<T> implements ElysiaCookie {
}

set value(value: T) {
this.setCookie.value = value
// Check if value actually changed before creating entry in jar
const current = this.cookie.value

// Simple equality check
if (current === value) return

// For objects, do a deep equality check
if (
typeof current === 'object' &&
current !== null &&
typeof value === 'object' &&
value !== null
) {
try {
if (JSON.stringify(current) === JSON.stringify(value)) return
} catch {
// If stringify fails, proceed with setting the value
}
}

// Only create entry in jar if value actually changed
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
this.jar[this.name].value = value
}

get expires() {
return this.cookie.expires
}

set expires(expires) {
this.setCookie.expires = expires
set expires(expires: Date | undefined) {
// Handle undefined values and compare timestamps instead of Date objects
const currentExpires = this.cookie.expires
if (currentExpires === undefined && expires === undefined) return
if (currentExpires?.getTime() === expires?.getTime()) return

if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
this.jar[this.name].expires = expires
}

get maxAge() {
return this.cookie.maxAge
}

set maxAge(maxAge) {
this.setCookie.maxAge = maxAge
set maxAge(maxAge: number | undefined) {
if (this.cookie.maxAge === maxAge) return
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
this.jar[this.name].maxAge = maxAge
}

get domain() {
return this.cookie.domain
}

set domain(domain) {
this.setCookie.domain = domain
set domain(domain: string | undefined) {
if (this.cookie.domain === domain) return
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
this.jar[this.name].domain = domain
}

get path() {
return this.cookie.path
}

set path(path) {
this.setCookie.path = path
set path(path: string | undefined) {
if (this.cookie.path === path) return
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
this.jar[this.name].path = path
}

get secure() {
return this.cookie.secure
}

set secure(secure) {
this.setCookie.secure = secure
set secure(secure: boolean | undefined) {
if (this.cookie.secure === secure) return
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
this.jar[this.name].secure = secure
}

get httpOnly() {
return this.cookie.httpOnly
}

set httpOnly(httpOnly) {
this.setCookie.httpOnly = httpOnly
set httpOnly(httpOnly: boolean | undefined) {
if (this.cookie.httpOnly === httpOnly) return
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
this.jar[this.name].httpOnly = httpOnly
}

get sameSite() {
return this.cookie.sameSite
}

set sameSite(sameSite) {
this.setCookie.sameSite = sameSite
set sameSite(
sameSite: true | false | 'lax' | 'strict' | 'none' | undefined
) {
if (this.cookie.sameSite === sameSite) return
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
this.jar[this.name].sameSite = sameSite
}

get priority() {
return this.cookie.priority
}

set priority(priority) {
this.setCookie.priority = priority
set priority(priority: 'low' | 'medium' | 'high' | undefined) {
if (this.cookie.priority === priority) return
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
this.jar[this.name].priority = priority
}

get partitioned() {
return this.cookie.partitioned
}

set partitioned(partitioned) {
this.setCookie.partitioned = partitioned
set partitioned(partitioned: boolean | undefined) {
if (this.cookie.partitioned === partitioned) return
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
this.jar[this.name].partitioned = partitioned
}

get secrets() {
return this.cookie.secrets
}

set secrets(secrets) {
this.setCookie.secrets = secrets
set secrets(secrets: string | string[] | undefined) {
if (this.cookie.secrets === secrets) return
if (!(this.name in this.jar)) this.jar[this.name] = { ...this.initial }
this.jar[this.name].secrets = secrets
}

update(config: Updater<Partial<ElysiaCookie>>) {
Expand Down
109 changes: 109 additions & 0 deletions test/cookie/unchanged.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
import { describe, it, expect } from 'bun:test'
import { Elysia, t } from '../../src'

describe('Cookie - Unchanged Values', () => {
it('should not send set-cookie header when cookie is only read', async () => {
const app = new Elysia()
.guard({
cookie: t.Cookie({
value: t.Optional(
t.Object({
a: t.String(),
b: t.String()
})
)
})
})
.get('/cookie', ({ cookie: { value } }) => value.value)
.post('/cookie', ({ cookie: { value } }) => {
value.value = { a: '1', b: '2' }
return 'ok'
})

// POST request should set cookie
const postResponse = await app.handle(
new Request('http://localhost/cookie', {
method: 'POST'
})
)

const setCookieHeaders = postResponse.headers.getAll('set-cookie')
expect(setCookieHeaders.length).toBeGreaterThan(0)

// GET request should NOT set cookie (only reading)
const getResponse = await app.handle(
new Request('http://localhost/cookie', {
method: 'GET',
headers: {
cookie: setCookieHeaders[0].split(';')[0]
}
})
)

const getSetCookieHeaders = getResponse.headers.getAll('set-cookie')
expect(getSetCookieHeaders.length).toBe(0)
})

it('should not send set-cookie header when cookie value is accessed but not modified', async () => {
const app = new Elysia()
.get('/read', ({ cookie: { session } }) => {
// Just reading the value
const val = session.value
return { read: val }
})
.get('/write', ({ cookie: { session } }) => {
// Writing the value
session.value = 'test'
return { written: true }
})

// Read endpoint should not set cookie
const readResponse = await app.handle(
new Request('http://localhost/read')
)
expect(readResponse.headers.getAll('set-cookie').length).toBe(0)

// Write endpoint should set cookie
const writeResponse = await app.handle(
new Request('http://localhost/write')
)
expect(
writeResponse.headers.getAll('set-cookie').length
).toBeGreaterThan(0)
})

it('should not send set-cookie header when setting same value', async () => {
const app = new Elysia().get('/same', ({ cookie: { session } }) => {
// Setting the same value that came from request
session.value = 'existing'
return 'ok'
})

const response = await app.handle(
new Request('http://localhost/same', {
headers: {
cookie: 'session=existing'
}
})
)

expect(response.headers.getAll('set-cookie').length).toBe(0)
})

it('should send set-cookie header when value actually changes', async () => {
const app = new Elysia().get('/change', ({ cookie: { session } }) => {
session.value = 'new-value'
return 'ok'
})

const response = await app.handle(
new Request('http://localhost/change', {
headers: {
cookie: 'session=old-value'
}
})
)

expect(response.headers.getAll('set-cookie').length).toBeGreaterThan(0)
})
})
38 changes: 38 additions & 0 deletions test/validator/cookie.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -462,3 +462,41 @@ describe('Cookie Validation', () => {
expect(await response.text()).toBe('empty')
})
})

it('expires setter compares timestamps not Date objects', async () => {
const app = new Elysia().get('/', ({ cookie: { session }, set }) => {
// Test 1: Setting expires with same timestamp should not update
const date1 = new Date('2025-12-31T23:59:59.000Z')
const date2 = new Date('2025-12-31T23:59:59.000Z')

session.value = 'test'
session.expires = date1

// Get reference to jar before setting with same timestamp
const jarBefore = set.cookie
session.expires = date2 // Same timestamp, should not update
const jarAfter = set.cookie

// Verify jar wasn't recreated (same reference)
expect(jarBefore).toBe(jarAfter)
expect(session.expires?.getTime()).toBe(date1.getTime())

// Test 2: Setting expires with different timestamp should update
const date3 = new Date('2026-01-01T00:00:00.000Z')
session.expires = date3
expect(session.expires?.getTime()).toBe(date3.getTime())

// Test 3: Both undefined should not update
session.expires = undefined
const jarBeforeUndefined = set.cookie
session.expires = undefined
const jarAfterUndefined = set.cookie
expect(jarBeforeUndefined).toBe(jarAfterUndefined)

return 'ok'
})

const response = await app.handle(req('/'))
expect(response.status).toBe(200)
expect(await response.text()).toBe('ok')
})
Loading