
Most Express applications do not need JWTs for user sessions.
If a user signs in to your app, the browser stores a cookie, and Express already looks that cookie up in a session store, a normal server-side session is usually the simpler option. Logout can delete one row and take effect immediately. You do not have to decide where a token lives, how refresh works, or how to revoke access that has already been handed out.
JWTs become useful when the service that receives the credential has to verify it without asking your session database. A common case is one internal service calling another, or an API that already speaks Bearer because an identity provider minted the token.
That distinction matters because JWTs are often added to Express simply because they look like the modern default. Developers then inherit problems they did not have before: where to store the token, how logout works, how long it should live, how refresh tokens rotate, and which claims the API must check.
So before you add jsonwebtoken or jose, ask one question: does another service actually need to verify this credential without access to my session store? If the answer is no, keep a session cookie. If the answer is yes, use a JWT, but verify it strictly. Pin the accepted algorithm, validate the issuer and audience, require an expiration time, and test that malformed or incorrectly scoped tokens return 401.
The rest of this guide covers both decisions: when an Express app should stick with sessions, and how to implement JWT verification safely when a token is genuinely necessary. Keep the CSRF page next to the cookie path, the secure coding checklist next to the claims, IDOR when the token is already valid and the object id is the question, and injection when the parser reshaped the request before your check ran.
A session cookie, a Bearer header, and a token in a JSON body are three different leak surfaces. Delete the session row and the cookie dies. A Bearer token stays valid until it expires. A token printed in a JSON body is available to every script that can read the response.
exp. A token in a JSON body has no revoke.
SecureCoding
Do you actually need JWTs? Usually no
A JWT is a signed, and sometimes encrypted, set of JSON claims. RFC 7519 names the claims. RFC 8725 is the current guidance for how to treat them. The format does not give you a session, a logout, or an authorization decision by itself. It moves a blob that some later service has to believe.
The comparison that matters is JWT versus sessions, not JWT versus cookies. Cookies are storage, the same way localStorage is storage. A session id in __Host-session and a JWT in __Host-access are the same storage choice with different payloads. latortuga made that split on Hacker News in 2019, and it still holds.
Reach for a JWT when one of these is true:
- Service-to-service. Billing needs to accept a token the identity service minted, without sharing a session store.
- Multi-audience. One issuer, several APIs, and each API must refuse a token that was not minted for it.
- A third-party API. You are the resource server. The client already speaks Bearer.
If none of those apply, keep the session. For a browser-facing Express app in 2026, that means express-session or your own store, a __Host-session cookie with HttpOnly, Secure, and SameSite=Lax, and the Sec-Fetch-Site check from the CSRF page. Putting a JWT in __Host-access instead is still a session, with extra steps and a worse logout.
The two common failures have names. CWE-347 is believing a token you should have dropped. CWE-613 is leaving a token valid longer than you can stand. Most B2B incidents are one of those two: the verifier accepted the wrong audience or algorithm, or nobody could revoke a token that was still inside its lifetime.
Session or token, on one grid
The rest of this page is for the cases where a token is actually required. Use the grid as a fork. If the first column already describes your app, you can skip jose.
| Cookie session | JWT you actually need | |
|---|---|---|
| What the browser holds | Opaque id in __Host-session | Signed claims in __Host-access or Bearer |
| Who can revoke today | Delete the row | Wait for exp, or look up a jti (that is a session store) |
| Logout everywhere | One DELETE | Only if you kept that store |
| When it earns its keep | One Express app, one cookie | Another service must verify without your session table |
Three questions before you add jose
Should I put a JWT in an httpOnly cookie?
Only if you already decided you need a JWT. Name it __Host-access and put the CSRF page on it: Sec-Fetch-Site, SameSite=Lax, Host prefix. For a browser-facing Express app that never needed a token, skip the JWT and keep a session id in __Host-session.
Is jsonwebtoken still fine in 2026?
The library section has the two package dates. If you stay on jsonwebtoken, pin algorithms, audience, and issuer before any other cleanup.
How do I log everyone out if I use JWTs?
Short access TTL, rotate __Host-refresh, detect reuse. For logout-everywhere, keep a jti or family id in a store and check it on every request. That store is a session store. If you need that store, start with a session and skip the token.
jose vs jsonwebtoken in 2026
As of 21 August 2026, the two package pages tell different stories. Copy-paste tutorials still name jsonwebtoken. jose is the library that documents a strict verifier on the landing page.
jose 6.2.9 has zero dependencies. It is ESM, with require(esm) on Node ^20.19, ^22.12, and 23+. The readme leads with jwtVerify and, on the same page, createRemoteJWKSet for a hosted JWKS. That helper fetches the key set, caches it, and retries when it sees an unknown kid. You do not need jwks-rsa to do the same job.
jsonwebtoken 9.0.3 is still maintained, and it is still the name every 2018 tutorial prints. The readme’s first verify example is jwt.verify(token, 'shhhhh'). Audience, issuer, and algorithm pinning are optional arguments you have to remember to pass. Remote keys need a second package and a getKey callback. Weekly downloads are still huge because of inertia, not because that shape is safer.
9.0.3 is not abandoned. The problem is the shape it still documents: a verifier that will ship without an algorithm pin unless you remember to add one. jose documents the stricter shape on the landing page.
// BAD: jsonwebtoken with no algorithms pin. Do not copy.
// no algorithms pin, no aud, no iss. do not copy this into a new app.
// const jwt = require("jsonwebtoken");
// const payload = jwt.verify(token, process.env.TOKEN_SECRET);
import { jwtVerify, createRemoteJWKSet } from "jose";
const JWKS = createRemoteJWKSet(
new URL("https://idp.example/.well-known/jwks.json")
);
// pass JWKS to jwtVerify. options live in the verification section.
Verification: alg, aud, iss, exp, clock skew
RFC 8725 gives three requirements that matter here: do not trust the token’s alg header to pick the algorithm, validate iss and aud as a set you configured, and give every token an exp you actually enforce.
The claims teams skip most often are aud and iss. In practice, several internal services share one identity provider and none of them check audience. A token minted for the analytics service then authenticates to billing. The signature is valid. The audience is wrong. The verifier never asked, which is why this is the common B2B JWT bug.
jwtVerify makes iss and aud required once you pass those options. A missing claim is a 401, same as a wrong value. requiredClaims: ["sub", "exp"] covers the rest you refuse to infer. jose’s verify options page says unsecured tokens (alg: none) are never accepted. You still pin algorithms. The default is “whatever the key type allows,” which is wider than one RS256 resource server should accept.
// JWKS is the createRemoteJWKSet from the library section.
async function requireAccess(req, res, next) {
const header = req.get("authorization") || "";
const token = header.startsWith("Bearer ") ? header.slice(7) : "";
if (!token) return res.status(401).end();
try {
const { payload } = await jwtVerify(token, JWKS, {
issuer: "https://idp.example",
audience: "https://billing.example",
algorithms: ["RS256"],
clockTolerance: 5,
requiredClaims: ["sub", "exp"],
});
req.actor = { sub: payload.sub, orgId: payload.org };
return next();
} catch (err) {
// JWKS fetch miss, unknown kid after retry, bad sig, bad claims: same door.
return res.status(401).end();
}
}
Clock skew is a number of seconds. It does not justify skipping exp. clockTolerance: 5 is five seconds. That covers two hosts whose clocks drifted. It does not cover a seven-day access token you issued because logout was hard.
nbf and iat are the other NumericDate claims. Enforce nbf if you mint it. maxTokenAge requires iat and rejects a token older than that window even if exp is still in the future. Use it when you want an absolute ceiling tighter than the issuer’s exp.
jose 6.2.4 and earlier treated a falsy option as “claim must exist, skip the compare.” An empty-string issuer, audience, or subject, or a maxTokenAge of 0, required the field and then accepted any value. The 29 July 2026 6.2.5 release compares whenever the option is defined. Pin 6.2.5 or newer, and do not pass process.env.AUD into jwtVerify until you have asserted it is a non-empty URL.
const audience = process.env.AUD;
if (typeof audience !== "string" || audience.length === 0) {
throw new Error("AUD must be a non-empty URL");
}
// pass audience into jwtVerify only after that check
Do not decode first and verify second. decodeJwt and jwt.decode are peek helpers. They do not check a signature. A handler that branches on decodeJwt(token).role before jwtVerify has already trusted the attacker. Verify, then read payload.
The subject tells you who the caller is. It does not tell you which row they may read. A valid sub does not authorize GET /invoices/:id. Put the tenant check in the query. The IDOR guide is the longer version of that rule.
Do not put an access token in a JSON body
theelderemo’s Suno.com post on Hacker News (10 October 2025) led with a JWT in the JSON body of an authenticated response. Finding 2 on that post is the object check: a valid user, the wrong song id. scuttmc called the writeup rushed. The two shapes still teach.
A token in a response body is a session you handed to every script, every extension, and every log line that printed the payload. MFA on the login does not follow it. The IDOR page owns the song-id half. The relevant finding here is do not put an access token in JSON you already authenticated with a cookie.
Where the token lives
A JWT in localStorage is a portable credential for its entire lifetime. The operator does not need your origin, your CORS, or a live browser. The leak surface is localStorage, not the token format.
A June 2026 Hacker News thread still split the two cookie flags from localStorage on exfil: XSS cannot read an httpOnly cookie, so it cannot lift the credential off-box, it can only ride the live browser. A token in localStorage leaves with the dump.
Two placements that survive that test:
__Host-access, host-bound. The token is now a cookie. You inherited CSRF. Pair it with the CSRF page: Sec-Fetch-Site first, SameSite=Lax as a belt, a token only for login and old clients. HttpOnly, Secure, path=/.- In-memory access token,
__Host-refresh. The SPA holds the short access token in a variable. The refresh token is the httpOnly cookie. A refresh loses the access token, which is the point. The refresh route is a cookie-authenticated state change. It needs the same CSRF check.
// Cookie placement. You now have CSRF. See the CSRF page.
res.cookie("__Host-access", token, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 10 * 60 * 1000,
});
res.cookie("__Host-refresh", refresh, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/auth/refresh",
maxAge: 12 * 60 * 60 * 1000,
});
Choose one placement and apply the matching defense: memory plus __Host-refresh, or __Host-access plus CSRF protection. Do not put the access token in localStorage because “SPAs do that.”
XSS still wins against a live session. An httpOnly cookie stops exfil. It does not stop the script from calling your API while the tab is open. That is the XSS page.
Revocation is the problem JWTs cannot solve cleanly
A signed token is valid until exp. The issuer cannot reach into the client’s pocket. Short TTL plus refresh rotation with reuse detection is the real pattern. Access tokens live minutes. The refresh token in __Host-refresh is one-time. Reuse of a spent refresh kills the family.
If you also need logout-everywhere, a password change that ends every device, or a compromised-bot response that cannot wait for exp, you keep a jti (or a family id) in a store and you check it on every request. A jti denylist is a session store, not a JWT feature. You have rebuilt sessions with extra steps: a signed blob and a lookup.
Stateless verification is real for the service-to-service case, where you accept the token lifetime as the kill switch. For a browser app that also wants instant logout, “stateless auth” is a slogan. You will end up with a store anyway.
Practical numbers for a token you actually need:
- Access: 5 to 15 minutes.
SignJWT.setExpirationTime("10m"). - Refresh: hours to a day, rotated, reuse-detected, stored server-side. That store is a session store. The cookie is
__Host-refresh. - Absolute session cap: 12 to 24 hours from the original login, even if the user keeps refreshing.
Do not put roles you might revoke into a four-hour access token and hope. If role can change, either the TTL is short enough that you accept the lag, or the verifier reads role from the account row after it trusts the sub.
Key rotation with JWKS and kid
The issuer publishes a JWKS. Each JWK has a kid. The token header carries that kid. The createRemoteJWKSet helper from the library section fetches the set, caches it, and on an unknown kid goes back to the URL. Failed fetch is the catch in requireAccess: 401.
Rotation that does not break verify:
- Generate the next key pair. Publish both the current and the next JWK in the JWKS. Different
kidvalues. - Start signing new tokens with the next key. Old tokens still verify against the current key, which is still in the set.
- Wait out the longest access TTL (and the refresh TTL, if refresh is also a JWT). Then drop the old JWK.
const JWKS = createRemoteJWKSet(
new URL("https://idp.example/.well-known/jwks.json"),
{
cooldownDuration: 30_000,
cacheMaxAge: 600_000,
}
);
// pin this URL in config. do not read jku from the token.
Pin the JWKS URL. Do not take a JWKS location from a token header. Do not follow a jku the client sent. That is the client picking your trust anchor for you.
HMAC (HS256) has no JWKS story worth using between services. A shared secret in two deploys is a rotation tax and a leak tax. RS256 or ES256, with the public half in the JWKS, is the service-to-service default. jose supports HMAC verification, so the library will not prevent you from choosing HS256. The constraint is operational. The API will not reject HMAC for you.
Verify your own verifier: five tokens, five 401s
Green-path testing is why broken verifiers ship. A login that returns 200 with a happy token does not prove aud is checked. You are not forging a production token. You are minting five bad tokens in a test helper that uses a key you generated for tests, then sending them at your own requireAccess.
Expected result for each: 401 and an empty body. A 200 is the miss. A 403 that still parsed the claims is also a miss. Fail closed before you read payload.sub.
- Expired.
expin the past. Clock tolerance must not save it. - Wrong aud. Signed by your test key,
audset tohttps://analytics.example. - Wrong iss. Same, with
https://other-idp.example. - Algorithm outside the allowlist. Mint with HS256. Send it at the RS256-only verifier. This tests the allowlist. Do not construct a cross-algorithm token against a production key.
- Garbage. A string that is not three base64url parts.
// Test helper only. Key you generated for tests. Never a production key.
import { SignJWT, generateKeyPair } from "jose";
const { publicKey, privateKey } = await generateKeyPair("RS256");
const now = Math.floor(Date.now() / 1000);
async function mint(claims, alg = "RS256", key = privateKey) {
return new SignJWT(claims)
.setProtectedHeader({ alg })
.setIssuedAt(now)
.sign(key);
}
const expired = await mint({
sub: "user-1", iss: "https://idp.example",
aud: "https://billing.example", exp: now - 60,
});
const wrongAud = await mint({
sub: "user-1", iss: "https://idp.example",
aud: "https://analytics.example", exp: now + 600,
});
const wrongIss = await mint({
sub: "user-1", iss: "https://other-idp.example",
aud: "https://billing.example", exp: now + 600,
});
const { createSecretKey } = await import("node:crypto");
const hs = createSecretKey(Buffer.from("test-only-not-prod-32-bytes-key!!"));
const algSwitched = await mint({
sub: "user-1", iss: "https://idp.example",
aud: "https://billing.example", exp: now + 600,
}, "HS256", hs);
const garbage = "not-a-jwt";
# Your own verifier. Tokens from the helper above. Do not send these at a host you do not own.
for t in "$EXPIRED" "$WRONG_AUD" "$WRONG_ISS" "$ALG_SWITCHED" "$GARBAGE"; do
curl -sS -D - -o /tmp/jwt-out -H "Authorization: Bearer $t" \
"https://your-app.example/account"
# Expect: HTTP/2 401
done
Point the helper’s publicKey at a test build of requireAccess, or stub the remote set with createLocalJWKSet and that public JWK. Production keeps the remote set. The five curls stay the same.
If any case returns 200, fix the verifier before you ship. Then add the five cases to CI so the next unpinned jwt.verify cannot land.
Chrome DBSC protects cookies, not Bearer tokens
Chrome’s Device Bound Session Credentials announcement says the feature is available in Chrome 145 on Windows. Chrome protects the private key in the TPM. The site opts in with a Secure-Session-Registration response header. The browser calls a registration endpoint with the public key, then a refresh endpoint when a bound cookie is near expiry, and proves it still holds the private key. The announcement names two endpoints and no change to the rest of the auth flow.
The announcement does not call macOS generally available. Treat macOS as not GA on that page.
Header-borne JWTs are outside that ceremony. DBSC binds a cookie to a device key. Authorization: Bearer ... is a string. Chrome will not TPM-wrap it for you. An infostealer that lifts a Bearer token still has a token. An infostealer that lifts a bound __Host-session still has a cookie, and it fails the refresh challenge on a different machine.
In practice, that is a 2026 reason to keep browser-facing Express apps on cookies, and to put the CSRF checks on those cookies, instead of moving the session into a header to “avoid CSRF.” CSRF is a cookie problem. Theft-and-replay is a bearer problem. DBSC raises the cost of exporting a cookie. It does not apply to the header.
DBSC does not end the class. Devices without a TPM fall back. Already-stolen cookies stay useful until the next refresh. Chrome-only today. Malware on the live device can still ride the session locally. Hardware binding raises the cost of export. It does not lock the box the user is sitting at. Do not emit Secure-Session-Registration until both endpoints exist.
// Only after /auth/dbsc/register and /auth/dbsc/refresh exist.
// Chrome falls back on its own when the device has no TPM. You do not.
res.set("Secure-Session-Registration", [
'challenge="PASTE_CHALLENGE_YOU_MINTED"',
"path=/auth/dbsc/register",
"authorization=Bearer",
].join("; "));
What the internet thinks about JWTs
Two community discussions capture the recurring argument:
Hacker Newslatortuga ยท 25 Nov 2019
“The correct comparison is JWT vs sessions, not JWT vs cookies. Cookies are a storage mechanism, and you would compare them to localStorage. Assuming you meant JWT vs sessions, yes, there aren’t any except that it’s super trendy.”
Other comments on that thread still treated a token in a cookie as the modern default. The split holds: a JWT in __Host-access is still a session with a signed payload. A session id in __Host-session is still the thing most Express apps needed.
Hacker Newstheelderemo ยท 10 Oct 2025
“Critical API endpoints return active JWT session tokens directly in the JSON response body. This allows for session hijacking and account takeover by any malicious browser extension, completely bypassing MFA.”
Suno disclosure, 10 October 2025. The same writeup also covered an IDOR issue. The relevant finding here is the access token exposed in the response body.



