JWTs became the default recommendation in a lot of tutorials because they solve a real problem for a specific kind of system: an API serving multiple independent clients, possibly across different domains or services, where a server checking a session store on every request would add real latency and coupling. For that shape of system, a signed token the client holds and the server can verify without a database lookup is a genuine win.
Most web apps are not that system. A typical product has one backend and one frontend, both under the team's control, and a session cookie backed by a fast in-memory or database-backed session store solves the same authentication problem with less complexity and fewer footguns.
The footguns are real. A JWT can't be revoked before it expires without adding a server-side blocklist, at which point the system has quietly reintroduced the database lookup it was meant to avoid, while keeping the added complexity of token signing and verification. Short expiry times mitigate this but push the problem into refresh token handling, which has its own well-documented set of mistakes, particularly around where the refresh token gets stored and how it's protected from theft.
Sessions, by contrast, revoke instantly since the session record simply gets deleted, work well with standard CSRF protections when the cookie is set correctly, and don't require the client to manage token storage and refresh logic at all.
The right default for a single-backend web app is usually a server-side session with a secure, httpOnly cookie. JWTs earn their place when there's a genuine multi-service or third-party API scenario: mobile clients hitting the same API as partner integrations, or a microservice architecture where services need to verify identity without a shared session store. Reach for the complexity when the problem actually requires it, not because it's the modern-sounding choice.

