Sep 29, 2025·8 min read

Session management for web apps: cookies vs JWTs vs refresh

Session management for web apps compared: cookie sessions, JWTs, and refresh tokens, using concrete threat models and realistic logout requirements.

Session management for web apps: cookies vs JWTs vs refresh

What session management is really doing

A session is how your app answers one question after someone logs in: "Who are you right now?" Once that answer is reliable, the app can decide what the user can see, what they can change, and what actions must be blocked.

"Staying logged in" is also a security choice. You're deciding how long a user identity should stay valid, where the proof of identity lives, and what happens if that proof gets copied.

Most web app setups rely on three building blocks:

  • Cookie-based server sessions: the browser stores a cookie, and the server looks up the session on each request.
  • JWT access tokens: the client sends a signed token that the server can verify without a database lookup.
  • Refresh tokens: a longer-lived credential used to get new, short-lived access tokens.

These aren't competing "styles" as much as different ways to handle the same trade-offs: speed vs control, simplicity vs flexibility, and "can we invalidate this right now?" vs "does it expire on its own?"

A useful way to evaluate any design: if an attacker steals whatever your app uses as proof (a cookie or token), what can they do, and for how long? Cookie sessions often win when you need strong server-side control, like forced logout or instant lockout. JWTs can be a good fit for stateless checks across services, but they get painful when you need immediate revocation.

No single option wins everywhere. The right approach depends on your threat model, how strict your logout requirements are, and how much complexity your team can realistically maintain.

Threat models that change the right answer

Good session design depends less on "the best" token type and more on which attacks you actually need to survive.

If an attacker steals data from browser storage (like localStorage), JWT access tokens are easy to grab because page JavaScript can read them. A stolen cookie is different: if it's set as HttpOnly, normal page code can't read it, so simple "token grab" attacks get harder. But if the attacker has the device (lost laptop, malware, shared computer), cookies can still be copied from the browser profile.

XSS (attacker code running in your page) changes everything. With XSS, the attacker may not need to steal anything. They can use the victim's already-logged-in session to perform actions. HttpOnly cookies help prevent reading session secrets, but they don't stop an attacker from making requests from the page.

CSRF (a different site triggering unwanted actions) mainly threatens cookie-based sessions, because browsers automatically attach cookies. If you rely on cookies, you need clear CSRF defenses: intentional SameSite settings, anti-CSRF tokens, and careful handling of state-changing requests. JWTs sent in an Authorization header are less exposed to classic CSRF, but they're still exposed to XSS if stored where JavaScript can read them.

Replay attacks (reusing a stolen credential) are where server-side sessions shine: you can invalidate a session ID immediately. Short-lived JWTs reduce replay time, but they don't stop replay while the token is valid.

Shared devices and lost phones turn "sign out" into a real threat model. The decisions usually come down to questions like: can a user force logout from other devices, how fast must it take effect, what happens if a refresh token is stolen, and do you allow "remember me" sessions? Many teams also hold staff access to a stricter standard than customer access, which changes timeouts and revocation expectations.

Cookie-based sessions are the classic setup. After sign-in, the server creates a session record (often an ID plus fields like user ID, created time, and expiry). The browser stores only the session ID in a cookie. On each request, the browser sends that cookie back, and the server looks up the session to decide who the user is.

The big security advantage is control. The session is validated on the server every time. If you need to kick someone out, you delete or disable the server-side session record and it stops working immediately, even if the user still has the cookie.

A lot of the protection comes from cookie settings:

  • HttpOnly: keeps JavaScript from reading the cookie.
  • Secure: sends the cookie only over HTTPS.
  • SameSite: limits when the browser sends the cookie on cross-site requests.

Where you store session state affects scaling. Keeping sessions in app memory is simple, but it breaks when you run multiple servers or restart often. A database works well for durability. Redis is common when you want fast lookups and many active sessions. The key point is the same: the server must be able to find and validate the session on every request.

Cookie sessions are a strong fit when you need strict logout behavior, like staff dashboards or customer portals where an admin must be able to force logout after a role change. If an employee leaves, disabling their server-side sessions ends access right away, without waiting for tokens to expire.

JWT access tokens: strengths and sharp edges

A JWT (JSON Web Token) is a signed string that carries a few claims about the user (like user ID, role, tenant) plus an expiry time. Your API verifies the signature and expiry locally, without calling a database, then authorizes the request.

That's why JWTs are popular in API-first products, mobile apps, and systems where multiple services need to validate the same identity. If you have multiple backend instances, each can verify the same token and get the same answer.

Strengths

JWT access tokens are fast to check and easy to pass along with API calls. If your frontend calls many endpoints, a short-lived access token can keep the flow straightforward: verify signature, read user ID, continue.

Example: a customer portal calls "List invoices" and "Update profile" on separate services. A JWT can carry the customer ID and a role like customer, so each service can authorize the request without a session lookup every time.

Sharp edges

The biggest trade-off is revocation. If a token is valid for one hour, it's usually valid everywhere for that hour, even if the user hits "log out" or an admin disables the account, unless you add extra server-side checks.

JWTs also leak in ordinary ways. Common failure points include localStorage (XSS can read it), browser memory (malicious extensions), logs and error reports, proxies and analytics tools that capture headers, and copied tokens in support chats or screenshots.

Because of this, JWT access tokens work best for short-lived access, not "forever login." Keep them minimal (no sensitive personal data inside), keep expiry short, and assume a stolen token will be usable until it expires.

Refresh tokens: making JWT setups workable

Prototype cookie sessions fast
Set up secure cookies and server validation without writing boilerplate code.
Start Building

JWT access tokens are meant to be short-lived. That's good for safety, but it creates a practical problem: users shouldn't have to log in again every few minutes. Refresh tokens solve that by letting the app quietly get a new access token when the old one expires.

Where you store the refresh token matters even more than where you store the access token. In a browser-based web app, the safest default is an HttpOnly, Secure cookie so JavaScript can't read it. Local storage is easier to implement, but it's also easier to steal if you ever have an XSS bug. If your threat model includes XSS, avoid putting long-lived secrets in JavaScript-accessible storage.

Rotation is what makes refresh tokens workable in real systems. Instead of using the same refresh token for weeks, you swap it each time it's used: the client presents refresh token A, the server issues a new access token plus refresh token B, and refresh token A becomes invalid.

A simple rotation setup usually follows a few rules:

  • Keep access tokens short (minutes, not hours).
  • Store refresh tokens server-side with status and last-used time.
  • Rotate on every refresh and invalidate the previous token.
  • Bind refresh tokens to a device or browser where possible.
  • Log refresh events so you can investigate abuse.

Reuse detection is the key alarm. If refresh token A was already exchanged, but you see it again later, assume it was copied. A common response is to revoke the whole session (and often all sessions for that user) and require a fresh login, because you can't know which copy is the real one.

For logout, you need something the server can enforce. That usually means a session table (or a revocation list) that marks refresh tokens as revoked. Access tokens may still work until they expire, but you can keep that window small by keeping access tokens short-lived.

Logout requirements and what is actually enforceable

Logout sounds simple until you define it. There are usually two different asks: "log out this device" (one browser or one phone) and "log out everywhere" (all active sessions across devices).

There's also a timing question. "Immediate logout" means the app stops accepting the credential right now. "Logout after expiry" means the app stops accepting it when the current session or token naturally expires.

With cookie-based sessions, immediate logout is straightforward because the server owns the session. You delete the cookie on the client and invalidate the server-side session record. If someone copied the cookie value earlier, the server rejection is what actually enforces logout.

With JWT-only auth (stateless access tokens and no server lookup), you can't truly guarantee immediate logout. A stolen JWT remains valid until it expires, because the server has nowhere to check "is this token revoked?" You can add a denylist, but then you're keeping state and checking it, which removes a lot of the original simplicity.

A practical pattern is to treat access tokens as short-lived and enforce logout through refresh tokens. The access token is allowed to coast for a few minutes, but the refresh token is what keeps a session alive. If a laptop is stolen, revoking the refresh token family cuts off future access quickly.

What you can realistically promise users:

  • Logout this device: revoke that session or refresh token, and delete local cookies or storage.
  • Logout everywhere: revoke all sessions or all refresh token families for the account.
  • "Immediate" effect: guaranteed with server sessions, best-effort with access tokens until they expire.
  • Forced logout events: password change, account disabled, role downgrade.

For password changes and account disable, don't rely on "the user will log out." Store an account-wide session version (or a "token valid after" timestamp). On each refresh (and sometimes on each request), compare it. If it changed, deny and require sign-in again.

Step-by-step: choosing a session approach for your app

Add reuse detection logic
Track refresh use and react to reuse with drag-and-drop business processes.
Set Up

If you want session design to stay simple, decide your rules first and only then pick the mechanics. Most problems start when teams pick JWTs or cookies because they're popular, not because they match the risks and logout requirements.

Start by listing every place a user signs in. A browser app behaves differently from a native mobile app, an internal admin tool, or a partner integration. Each one changes what can be safely stored, how logins are renewed, and what "logout" should mean.

A practical order that works for most teams:

  1. List your clients: web, iOS/Android, internal tools, third-party access.
  2. Pick a default threat model: XSS, CSRF, stolen device.
  3. Decide what logout must guarantee: this device, all devices, admin forced logout.
  4. Choose a baseline pattern: cookie-based sessions (server remembers) or access token + refresh token.
  5. Set timeouts and response rules: idle vs absolute expiry, plus what you do when you see suspicious reuse.

Then document the exact promises your system makes. Example: "Web sessions expire after 30 minutes idle or 7 days absolute. Admin can force logout within 60 seconds. Lost phone can be disabled remotely." Those sentences matter more than the library you use.

Finally, add monitoring that matches your pattern. For token setups, a strong signal is refresh token reuse (the same refresh token used twice). Treat it as likely theft, revoke the session family, and alert the user.

Common mistakes that lead to account takeover

Change requirements without debt
Update auth rules quickly by regenerating clean code when requirements change.
Regenerate Code

Most account takeovers aren't "smart hacks." They're simple wins caused by predictable session mistakes. Good session handling is mostly about not giving attackers an easy way to steal or replay credentials.

One common trap is putting access tokens in localStorage and hoping you never get XSS. If any script runs on your page (a bad dependency, an injected widget, a stored comment), it can read localStorage and send the token out. Cookies with the HttpOnly flag reduce that risk because JavaScript can't read them.

Another trap is making JWTs long-lived to avoid refresh tokens. A 7-day access token is a 7-day reuse window if it leaks. A short access token plus a well-managed refresh token is harder to abuse, especially when you can cut off refresh.

Cookies bring their own foot-gun: forgetting CSRF defenses. If your app uses cookie sessions and you accept state-changing requests without CSRF protection, a malicious site can trick a logged-in browser into sending valid requests.

Other mistakes that often show up after incident reviews:

  • Refresh tokens never rotate, or they rotate but you don't detect reuse.
  • You support multiple login methods (cookie session and bearer token) but the server's "which one wins" rule is unclear.
  • Tokens end up in logs (browser console, analytics events, server request logs), where they get copied and retained.

A concrete example: a support agent pastes a "debug log" into a ticket. The log includes an Authorization header. Anyone with ticket access can replay that token and act as the agent. Treat tokens like passwords: don't print them, don't store them, and keep them short-lived.

Quick checks before you ship

Most session bugs aren't about fancy crypto. They're about one missing flag, one token that lives too long, or one endpoint that should have required re-auth.

Before release, do a short pass focused on what an attacker can do with a stolen cookie or token. It's one of the fastest ways to improve security without rewriting your whole auth setup.

Pre-release checklist

Walk through these checks in staging, then again in production settings:

  • Keep access tokens short-lived (minutes), and confirm the API actually rejects them after expiry.
  • Treat refresh tokens like passwords: store them where JavaScript can't read them if possible, send them only to the refresh endpoint, and rotate after every use.
  • If you use cookies for auth, verify flags: HttpOnly on, Secure on, and SameSite set intentionally. Also confirm cookie scope (domain and path) isn't wider than needed.
  • If cookies authenticate requests, add CSRF defenses, and confirm state-changing endpoints fail without the CSRF signal.
  • Make revocation real: after password reset or account disable, existing sessions should stop working quickly (server-side session delete, refresh token invalidation, or a "session version" check).

After that, test your logout promises. "Log out" often means "remove local session," but users expect more.

A practical test: log in on a laptop and phone, then change the password. The laptop should be forced out on its next request, not hours later. If you offer "log out everywhere" and a device list, confirm each device maps to a distinct session or refresh token record you can revoke.

Example: a customer portal with staff accounts and forced logout

Model sessions and devices
Define users, devices, and tokens in PostgreSQL with AppMaster Data Designer.
Model Data

Picture a small business with a web customer portal (customers check invoices, open tickets) and a mobile app for field staff (jobs, notes, photos). Staff sometimes work in basements with no signal, so the app must keep working offline for a bit. Admins also want a big red button: if a tablet is lost or a contractor leaves, they can force a logout.

Now add three common threats: shared tablets in vans (someone forgets to sign out), phishing (a staff member types credentials into a fake page), and an occasional XSS bug in the portal (a script runs in the browser and tries to steal whatever it can).

A practical setup here is short-lived access tokens plus rotating refresh tokens, with server-side revocation. It gives you fast API calls and offline tolerance, while still letting admins cut sessions off.

Here's what that can look like:

  • Access token lifetime: 5 to 15 minutes.
  • Refresh token rotation: every refresh returns a new refresh token, and the old one is invalidated.
  • Store refresh tokens safely: on web, keep the refresh token in an HttpOnly, Secure cookie; on mobile, keep it in OS secure storage.
  • Track refresh tokens server-side: store a token record (user, device, issued time, last used, revoked flag). If a rotated token is reused, treat it as theft and revoke the whole chain.

Forced logout becomes enforceable: the admin revokes the refresh token record for that device (or all devices for that user). The stolen device can keep using the current access token until it expires, but it can't get a new one. So the maximum time to fully cut access is your access token lifetime.

For a lost device, define the rule in plain language: "Within 10 minutes, the app will stop syncing and will require sign-in again." Offline work can remain on the device, but the next online sync should fail until the user signs in.

Next steps: implement, test, and keep it maintainable

Write down what "logout" means in plain product language. For example: "Logging out removes access on this device," "Logging out everywhere kicks out all devices within 1 minute," or "Changing your password logs out other sessions." Those promises decide whether you need server-side session state, revocation lists, or short-lived tokens.

Turn the promises into a small test plan. Token and session bugs often look fine in happy-path demos, then fail in real life (sleep mode, spotty networks, multiple devices).

A practical test checklist

Run tests that cover the messy cases:

  • Expiry: access stops when the access token or session expires, even if the browser stays open.
  • Revocation: after "logout everywhere," the old credential fails on the next request.
  • Rotation: refresh token rotation issues a new refresh token and invalidates the old one.
  • Reuse detection: replaying an old refresh token triggers a lock-down response.
  • Multi-device: rules for "current device only" vs "all devices" are enforced and the UI matches.

After tests, do a simple attack rehearsal with your team. Pick three stories and walk through them end-to-end: an XSS bug that can read tokens, a CSRF attempt against cookie sessions, and a stolen phone with an active session. You're checking whether your design matches your promises.

If you need to move fast, reduce custom glue code. AppMaster (appmaster.io) is one option when you want a generated, production-ready backend plus web and native mobile apps, so you can keep rules like expiry, rotation, and forced logout consistent across clients.

Schedule a follow-up review after launch. Use real support tickets and incidents to adjust timeouts, session limits, and "logout everywhere" behavior, then re-run the same checklist so fixes don't quietly regress.

Easy to start
Create something amazing

Experiment with AppMaster with free plan.
When you will be ready you can choose the proper subscription.

Get Started