October 10, 2026
Cookies vs JWTs: Which Is Actually Safer for Web Authentication?
One stores your session. The other carries information about it. But which approach actually keeps attackers out?

By Haroon Shahid
12 min read
Imagine a developer proudly telling you:
"We've upgraded our authentication system. We don't use cookies anymore. We use JWTs. Much more secure!"
Sounds impressive.
Until you open Caido, inspect an authenticated request, and see something like this:
Cookie: access_token=eyJhbGciOiJIUzI1NiIs...
Hold on.
That's a JWT.
Inside a cookie.
So much for getting rid of cookies.
It's a funny example, but it highlights a misunderstanding I keep seeing in discussions about web authentication.
People compare cookies and JWTs as though they're two competing technologies, and switching from one to the other automatically makes an application more secure.
But here's the thing.
A cookie and a JWT aren't even the same kind of technology.
And once you understand that difference, some surprisingly important security questions start to appear.
First, what exactly is a cookie?
Let's say you log into an online shopping website.
You enter your email and password.
The server verifies your credentials and creates a session.
It needs some way to recognize you when you open your cart, check your orders, or update your address.
Otherwise, you'd have to enter your password every time you clicked something.
One common approach is to generate a random session identifier and send it to your browser.
The response might contain:
Set-Cookie: session_id=a8f92c7d1e; HttpOnly; Secure; SameSite=Lax; Path=/
Your browser stores the cookie.
From that point onward, it automatically includes the cookie in requests where the cookie's scope and browser rules permit it.
For example:
GET /my-account
Cookie: session_id=a8f92c7d1e
The server receives the session ID, looks up the corresponding session, and recognizes your account.
Simple.
The important detail is that the cookie itself doesn't have to contain your username, password, or permissions.
It can contain nothing more than an unpredictable identifier.
The server keeps the actual session information on its side.
Think of it like getting a numbered ticket at a restaurant.
The ticket identifies your order, but it doesn't need to contain the entire order.
The restaurant has the details.
Of course, a session cookie is more sensitive than an ordinary restaurant ticket. If someone steals a valid session identifier, they may be able to impersonate the user.
We'll come back to that.
Now, what exactly is a JWT?
JWT stands for JSON Web Token.
Unlike a cookie, which is a browser mechanism for storing and sending data, a JWT is a structured token format.
A typical signed JWT contains three parts separated by dots:
HEADER.PAYLOAD.SIGNATURE
Each part has a purpose.
The header describes information such as the signing algorithm.
The payload contains claims.
Claims are pieces of information about the token or its subject.
For example, imagine decoding a JWT and finding this payload:
{"sub":"1042","role":"user","exp":1791500000}
Here:
subidentifies the subject, which might be a user.roleis an application-defined claim describing a role.expspecifies the token's expiration time.
The signature allows the server to check whether the signed token has been tampered with and whether it was signed by a trusted key.
Notice something interesting?
With a properly designed signed JWT, the server can verify the token and use its claims without necessarily looking up a traditional session record on every request.
That's one reason JWTs can be useful in distributed systems and APIs.
But there's something many beginners misunderstand.
A typical signed JWT is not encrypted.
Its header and payload are usually just Base64URL-encoded.
Anyone who obtains the token can decode those parts and read the claims.
The signature protects their integrity and authenticity. It doesn't hide them.
Encrypted JWTs exist, but they use a different construction.
So if you put passwords, secret keys, or unnecessarily sensitive personal information inside an ordinary signed JWT, you're creating an avoidable exposure risk.
Here's where the comparison gets confusing
Suppose we have two applications.
Application A uses a traditional session.
Its request contains: Cookie: session_id=a8f92c7d1e
Application B uses a JWT access token.
Its request contains: Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
At first glance, that looks like cookies versus JWTs.
But it isn't quite that simple.
The first application uses an opaque session identifier, transported in a cookie.
The second uses a JWT, transported in an authorization header.
And guess what?
Application B could also put its JWT inside a cookie.
Application A could even transport its opaque session token through an authorization header.
These are separate decisions:
What kind of credential are we using?
And:
How does the browser store and send that credential?
Once you separate those questions, comparing the security of different approaches becomes much easier.
Now let's get into the attacks.
Scenario 1: What happens if an attacker steals your cookie?
Imagine Alice is logged into an application.
Her browser has a valid session cookie: session_id=a8f92c7d1e
The server associates that session ID with Alice.
Now suppose an attacker somehow obtains the value.
If the application relies on that cookie as a bearer credential, the attacker might be able to send: Cookie: session_id=a8f92c7d1e
And the server may treat the request as coming from Alice.
No password required.
No need to guess her username.
The attacker possesses a credential that proves access to Alice's existing session.
This is called session hijacking.
But there's an important detail.
Cookies support security attributes that can make certain attacks harder.
Let's examine three particularly important ones.
1. HttpOnly
Consider: Set-Cookie: session_id=a8f92c7d1e; HttpOnly
The HttpOnly attribute prevents ordinary JavaScript running in the page from reading the cookie through APIs such as document.cookie.
That can make stealing session identifiers through XSS significantly harder.
But HttpOnly doesn't magically eliminate XSS.
An injected script may still be able to perform unauthorized actions through the victim's browser because the browser continues to attach eligible cookies to requests.
So HttpOnly protects against one important form of credential theft.
It doesn't make an application immune to malicious scripts.
2. Secure
Now consider: Set-Cookie: session_id=a8f92c7d1e; Secure
The Secure attribute tells the browser to send that cookie only over HTTPS, apart from specific localhost behavior.
That helps prevent the session cookie from being exposed over unencrypted HTTP.
However, HTTPS and Secure don't prevent every kind of session theft.
A vulnerable application, compromised device, or inappropriate logging system can still expose credentials.
3. SameSite
Finally: Set-Cookie: session_id=a8f92c7d1e; SameSite=Lax
The SameSite attribute controls when a browser sends cookies with requests originating from other sites.
It's particularly relevant to Cross-Site Request Forgery (CSRF).
There are three main settings:
Strict: Strongly restricts cross-site cookie sending.Lax: Allows cookies in some cross-site situations, such as certain top-level navigations using safe HTTP methods.None: Allows cross-site sending, provided the requiredSecureattribute is present.
These settings can reduce CSRF exposure.
But SameSite should not automatically be treated as a complete replacement for proper CSRF defenses.
So cookies aren't inherently unsafe.
They give developers several useful security controls.
The real question is whether those controls are configured correctly and whether the underlying session is securely managed.
Scenario 2: What happens if an attacker steals your JWT?
Let's say another application uses JWT authentication.
After login, the server returns an access token.
The browser stores it, and subsequent requests include:
Authorization: Bearer eyJhbGciOiJIUzI1NiIs...
If an attacker steals a valid bearer token, they may be able to use it to impersonate the legitimate user, depending on its scope, expiration, and any additional protections.
Sound familiar?
It should.
That's because both opaque session identifiers and bearer JWTs can grant access to an account.
Stealing either can be dangerous.
So the next question is:
Where is the JWT stored?
Because this changes the security discussion considerably.
The localStorage problem
Some developers store JWTs in the browser's localStorage.
For example:
localStorage.setItem("access_token", token)
Why?
It's convenient.
The token remains available even after the browser closes, unless it's explicitly removed or browser storage is cleared.
JavaScript can retrieve it whenever it needs to send an API request.
But that convenience comes with a security trade-off.
Any JavaScript executing with the application's origin can potentially access the token stored there.
Now imagine the website has a Cross-Site Scripting vulnerability.
An attacker manages to get malicious JavaScript executed in the victim's browser.
If the JWT is stored in localStorage, that script may be able to retrieve it.
And if the stolen token is a reusable bearer credential, the attacker may be able to use it from another environment.
That's a serious problem.
Compare this with an opaque session identifier stored in an appropriately configured HttpOnly cookie.
Injected JavaScript normally cannot read the cookie's value directly.
That's a meaningful advantage.
But don't misunderstand the conclusion.
The danger isn't that the credential is a JWT.
The danger is exposing a sensitive authentication credential to JavaScript that an attacker might control.
You could store an opaque session token in localStorage and create a similar risk.
And you could store a JWT in an HttpOnly cookie, avoiding direct JavaScript access to the token.
Current OWASP session-management guidance recommends avoiding authentication credentials in browser Web Storage for precisely this reason.
Wait. So can a JWT be stored inside an HttpOnly cookie?
Yes.
And this is where the entire "Cookies vs JWTs" argument starts falling apart.
Imagine the server responds with:
Set-Cookie: access_token=eyJhbGciOiJIUzI1NiIs...; HttpOnly; Secure; SameSite=Lax; Path=/
Now the browser stores a JWT inside a cookie.
JavaScript can't directly read the cookie value because of HttpOnly.
The browser automatically includes it in eligible requests.
And the server validates the JWT instead of looking up a traditional session identifier.
You're using both technologies together.
There's no contradiction.
However, automatic cookie sending also means you need to consider CSRF.
Simply putting a JWT inside an HttpOnly cookie doesn't make the application immune to cross-site requests.
The developer still needs an appropriate CSRF defense for the application.
Which brings us to an important point.
Moving a token from a cookie into an authorization header changes how the browser sends it. It doesn't automatically fix every security problem.
Scenario 3: What happens when the user logs out?
This is probably my favorite part of the comparison.
And it's an excellent thing to test during a web penetration test.
Imagine Alice is using a traditional server-side session.
She logs in, receives a session identifier, and starts browsing.
Later, she clicks Logout.
If the server is correctly implemented, it invalidates Alice's session record.
Even if someone tries to reuse that old session identifier, the server should reject it.
Now consider an application using self-contained JWT access tokens.
Alice logs in and receives a JWT that expires in 30 minutes.
She clicks Logout after five minutes.
The application removes the token from the browser.
Looks good.
But wait.
What happens if someone saved a copy of that JWT before Alice logged out?
If the server checks only that the token's signature and claims are valid, it may continue accepting that token until it expires.
Deleting a token from the browser doesn't invalidate every existing copy.
That's one of the challenges with stateless JWT authentication.
A JWT doesn't automatically provide server-side revocation.
There are solutions, of course.
An application can use short-lived access tokens, maintain a revocation list, manage refresh tokens carefully, or use other token-status mechanisms.
But those controls have to be designed and implemented.
And some of them introduce server-side state, reducing the simplicity that developers originally wanted from stateless JWTs.
So here's a useful question for a pentester:
Does logging out actually invalidate the credential, or does it just remove it from the browser?
Don't assume the answer.
Test it.
How I'd test cookies and JWTs using Caido
Let's turn this into something practical.
You don't need to start by throwing hundreds of payloads at an application.
First, understand how authentication works.
Use an authorized test application or a deliberately vulnerable lab.
Step 1: Log in and inspect the request
Open Caido and capture the login process.
Look at the server's response.
Does it contain a Set-Cookie header?
Does it return a token in JSON?
For example:
{"access_token":"eyJhbGciOi..."}
Then capture a request to an authenticated endpoint, such as:
GET /api/me
Look for a cookie or an authorization header.
This tells you how the application sends the authentication credential.
It doesn't necessarily tell you everything about its internal session architecture, but it's a useful starting point.
Step 2: Inspect cookie security attributes
If the application uses cookies, check:
- Is
HttpOnlypresent on the session cookie? - Is
Secureenabled? - What is the
SameSitesetting? - Is the cookie unnecessarily available to subdomains?
- Is the session identifier rotated after login?
Remember that cookie attributes are visible in HTTP traffic and browser developer tools, even when JavaScript is prevented from reading the cookie.
Also check whether the server correctly enforces authorization.
A perfectly configured cookie can still authenticate a user to a vulnerable API.
Step 3: Examine JWT claims
If you find a JWT, inspect its header and payload using Caido's decoding capabilities or another trusted local JWT decoder.
Avoid pasting real production tokens into random online websites.
Look for claims such as:
sub, iss, aud, exp, and application-specific role information.
Ask yourself:
What does this token claim?
Who issued it?
Who is supposed to accept it?
When does it expire?
A JWT payload can be decoded without verifying its signature, so don't confuse readable claims with trusted claims.
The server must independently validate the token before relying on its contents.
Step 4: Check JWT validation in a lab
Suppose a lab token contains: "role":"user"
You modify the payload to say: "role":"admin"
A properly implemented server should reject a modified signed JWT if its signature is no longer valid.
If it accepts the modification and grants additional privileges, something is seriously wrong.
There are several possible JWT validation flaws, including incorrect signature verification and unsafe algorithm handling.
But a decoded or edited JWT isn't automatically a valid JWT.
And even a correctly signed token doesn't automatically authorize every action.
Authorization must still be enforced on the server.
Step 5: Test logout and session invalidation
This test can reveal some surprisingly important behavior.
While logged in to your own account, capture an authenticated request and save it in Caido Replay.
Now log out normally.
Return to Replay and resend the original request with the original authentication credential.
Does the server still accept it?
If yes, investigate the application's intended logout behavior, token lifetime, and session-revocation design.
For a traditional server-side session, continued acceptance may indicate a session invalidation flaw.
For a short-lived JWT, continued validity might reflect the application's documented token lifecycle, although it can still create security risks depending on the situation.
Don't treat every token that survives logout as an automatically exploitable vulnerability.
Understand the security requirement and demonstrate the impact.
Step 6: Test what happens after changing the password
Here's another interesting question.
You change your account password.
What happens to existing sessions or tokens?
Can an old token still access your account?
Does the application invalidate other sessions?
Does it require reauthentication for sensitive operations?
The answer may depend on the application's risk level and session-management policies.
For a financial application, weak session invalidation could have serious consequences.
Again, the important thing is to understand what security guarantee the application is supposed to provide.
Cookies vs JWTs: The actual comparison
To make this comparison fair, let's compare two specific architectures:
A traditional server-side session using an opaque ID in an HttpOnly cookie, and a stateless signed JWT used as an access token.
The important observation is that neither architecture is automatically secure.
And the token format alone doesn't determine whether the browser exposes credentials to JavaScript.
So which one would I choose?
For a normal browser-based web application, I'd generally lean toward a well-implemented server-side session using a hardened HttpOnly cookie.
Why?
Because it's relatively straightforward to manage, can keep session data on the server, and makes session invalidation easier.
There is often no good reason to introduce stateless JWT sessions merely because JWTs sound more modern.
But that doesn't mean JWTs are a bad technology.
They can be extremely useful when multiple services need to validate tokens, when standardized claims are valuable, or when an authentication architecture genuinely benefits from them.
The key is understanding the trade-offs.
If you choose JWTs, you need to think carefully about signing and validation, token storage, expiration, revocation, and how credentials are refreshed.
If you choose traditional sessions, you still need secure session generation, storage, rotation, expiration, cookie configuration, and CSRF protection.
Neither choice gets you out of doing the security work.
One last thing: authentication isn't authorization
There's an easy mistake to make here.
Imagine your JWT contains: {"sub":"1042","role":"user"}
The server verifies the signature.
The token is valid.
Great.
But does that mean User 1042 should be allowed to access every invoice, document, or account in the application?
Of course not.
A valid token proves certain claims have been issued by a trusted authority.
It doesn't automatically prove the user has permission to perform every requested operation.
And exactly the same principle applies to traditional session cookies.
A valid session doesn't make an IDOR disappear.
A signed JWT doesn't replace object-level authorization.
You can build a technically excellent authentication system and still have terrible access control.
That's why security testing can't stop after verifying the token.
You need to inspect what the application allows the authenticated user to do.
The final verdict
So, cookies or JWTs?
Here's the answer.
Cookies aren't inherently less secure than JWTs, and JWTs aren't automatically an upgrade over traditional sessions.
Cookies provide a mechanism for storing and transporting data between a browser and server.
JWTs provide a structured way to carry claims that can be cryptographically verified.
They can be used separately.
They can also be used together.
The security of an authentication system depends on much more than which one appears in the request.
It depends on how credentials are generated, where they're stored, how they're transmitted, how they're validated, and how they're invalidated.
So the next time someone proudly tells you their application is secure because they replaced session cookies with JWTs, ask them something simple:
"Where do you store the token, and what happens if someone steals it?"
Their answer will tell you far more about the application's security than the three letters JWT ever could.