JWT auth without the confusion
The mental model that fixes everything JWT is just a token format . It is not authentication, not a session, and not a database. Once you separate those ideas, most of the pain disappears. A JWT is a JSON object that is signed. That's it. The payload holds claims like sub (subject) and exp (expiration). The signature proves the token wasn't tampered with. What JWT is not Not a session store : You can't revoke a JWT before it expires. If you need revocation, you need a blocklist or short expiry. Not a database : Don't stuff heavy data in the payload. It gets sent on every request. Not a magic bullet : It's a way to pass claims between parties without a shared server-side state. The three flows that matter 1. Access token only Simplest flow: login returns a JWT, client sends it in the Authorization header, server verifies it on every request. // server middleware (Express example) const jwt = require ( ' jsonwebtoken ' ); function auth ( req , res , next ) { const header = req . headers . authorization ; if ( ! header ) return res . status ( 401 ). json ({ error : ' No token ' }); const token = header . split ( ' ' )[ 1 ]; // Bearer <token> try { req . user = jwt . verify ( token , process . env . JWT_SECRET ); next (); } catch ( err ) { res . status ( 401 ). json ({ error : ' Invalid token ' }); } } Works fine for small apps, but every request hits your auth logic and the token can't be invalidated early. 2. Access + refresh token Common pattern for SPAs. Access token lives 15 minutes, refresh token lives 7 days. The refresh token is stored securely (httpOnly cookie) and used only to get a new access token. // issue tokens on login const accessToken = jwt . sign ({ userId }, process . env . JWT_SECRET , { expiresIn : ' 15m ' }); const refreshToken = jwt . sign ({ userId }, process . env . REFRESH_SECRET , { expiresIn : ' 7d ' }); res . json ({ accessToken }); res . cookie ( ' refreshToken ' , refreshToken , { httpOnly : true , secure : true , sameSite : ' strict ' })