Login is the part of authentication that gets the attention and it is the smaller half of the problem. A login happens once. The session happens on every single request afterwards, which means the session layer is both your highest traffic security control and, if you built it on a stateful store, quietly the highest queries-per-second service you own.
The failure that teaches this lesson is always the same one. Someone reports a compromised account. You disable the user, force a password reset, and tell the customer it is handled. The attacker keeps working for the next fifty minutes, because the access token they hold is a signed statement with an expiry and your API is verifying the signature rather than asking anything about the user. Nothing is broken. The system is doing exactly what you designed it to do. You just did not notice that you had designed away the ability to say no.
That is the honest framing for this entire category. A stateless token you cannot revoke is not a design decision you made about scalability, it is a feature request you have not built yet, and everything below is about where you choose to put the state that makes revocation possible.
The second reason this layer deserves more care than it gets is that the mistakes are silent. A missing SameSite attribute does not throw. A session ID that is not regenerated after login does not appear in any test. A refresh token that can be replayed twice looks exactly like a refresh token that cannot, right up until someone replays it. None of these produce an error your monitoring will catch, which is why the failure usually surfaces as an incident rather than as a bug.
Key takeaways
- Opaque session IDs give you revocation for free and cost a store lookup per request. JWTs give you stateless verification and cost you the ability to revoke, unless you add the state back.
- Refresh token rotation is only worth implementing with reuse detection: when an already-used refresh token is presented, kill the whole family, because that is the signature of a stolen token.
SameSite=Laxblocks the cookie on cross-site POST but sends it on top-level navigation, which is why it stops most CSRF and does not stop all of it.NonerequiresSecureand re-opens the hole.- Regenerate the session identifier at every privilege change, especially login. A session fixation bug is three lines to prevent and produces a full account takeover if you skip it.
JWT: a standard, not a session system
A JWT is an encoding for a set of claims with a signature over them. That is the entire specification. It has no opinion about sessions, no opinion about logout, and no opinion about how long anything should live. Vendors sell products around it; nobody sells you a JWT.
What it gives you
Verification without a lookup. A service holding the public key or shared secret can validate the signature and read the claims locally. No network call, no shared database, no coupling to an auth service at request time. For a fleet of services that need to know who the caller is, this is genuinely valuable, and it is the reason JWTs won in service-to-service and API contexts.
A portable, self-describing credential. The claims travel with the request. A downstream service sees the subject, the issuer, the audience and the expiry without asking anyone.
Asymmetric signing, so verification does not imply the ability to mint. With RS256 or ES256, the issuer holds the private key and everyone else holds the public key from the JWKS endpoint. A compromised verifying service cannot issue tokens. With HS256 the shared secret does both, which means every service that can check a token can also forge one.
Standard claims with real semantics. exp, nbf, iss, aud and jti exist so that verification is more than a signature check. Verifying the signature but not the audience is a real vulnerability, because a token minted for one service is then accepted by another.
What it does not do
It does not give you revocation. This is the central point of the whole category. A signed token is valid until it expires because validity is a property of the signature and the clock, not of a decision you can change. Logout does not invalidate it. Disabling the user does not invalidate it. Rotating a password does not invalidate it. The only lever you have is expiry.
There are three honest ways out, and each puts state back somewhere:
- Short expiry plus a refresh token. The access token lives minutes, and the refresh exchange is where you check whether the user is still allowed in. This is the standard answer, and the revocation window is exactly your access token lifetime. Your incident response has to live with that number.
- A denylist checked on every request. You get real revocation, and you have reintroduced a lookup on every request, which is the cost you adopted JWTs to avoid. It is usually a small lookup against a fast store keyed on
jti, so it is not fatal, but be clear that you now run a stateful session system with extra cryptography. - A version or epoch claim checked against a cached user record. The token carries a counter, the user record carries a counter, and bumping the user’s counter invalidates every token issued before it. Cheaper than a per-token denylist, and it only supports revoking everything for a user rather than one specific session.
It does not provide confidentiality. A signed JWT is base64url encoded, not encrypted. Anything in the payload is readable by anyone who holds the token, including the user. Putting an internal identifier, an email address or a role hierarchy in there is a disclosure decision, not an implementation detail.
It does not protect itself from bad verification. The historical alg: none problem and key confusion attacks, where a token signed with a public key as an HMAC secret is accepted, both come from verifiers that trusted the token to describe how it should be checked. Pin the expected algorithm in your verification configuration and never let the header choose.
It does not belong in localStorage. A token readable by JavaScript is a token any cross-site scripting bug exfiltrates. The mitigation is not a better content security policy; it is not giving JavaScript the credential in the first place.
Opaque sessions: the other half of the tradeoff
An opaque session identifier is a long random value that means nothing by itself. The server looks it up in a store and finds the session record: user, expiry, factors satisfied, device, whatever you put there.
Everything a JWT struggles with, this shape handles by construction:
Revocation is a delete. Logout, disable user, security incident, password change, recovery event: delete the rows and every request afterwards fails. There is no window.
Claims can change immediately. Roles, permissions, tenant membership and plan tier are read from the store, so a permission change applies on the next request rather than on the next token issue. For anything with an authorisation model that changes during a session, this alone is decisive. The authorization service guide covers what happens when those checks get complicated.
Nothing sensitive is exposed. The identifier is random and carries no information.
You can enumerate and manage sessions. “Show me my active sessions and let me sign out the one in another country” is trivial with a store and awkward without one.
The price is one lookup per authenticated request, and that is where the operational story starts, because the store becomes your busiest service. More on that below.
The hybrid, which is what most production systems actually run
In practice the answer for a web application is usually both. A short-lived access token, sometimes a JWT, carries identity to your services without a lookup. A long-lived refresh token or session record lives in a store, is checked at refresh time, and is the thing you can actually revoke. The revocation window is your access token lifetime, and you choose that number deliberately with an incident in mind rather than copying a default.
For a first party web application where the browser talks to your own backend, plain opaque session cookies remain an excellent and underrated choice. The JWT advantages mostly matter when the token crosses a trust boundary you do not control or reaches services that cannot query your store. For a monolith with a session store, a JWT is added complexity in exchange for a property you were not using. The cross-boundary case is covered in the API authentication guide.
Needs first-hand data: Time the actual revocation window in your own system. Disable a test user, then poll an authenticated endpoint with a token issued a moment earlier and record how long it keeps working. Do the same for a logout. That number is what you will have to put in an incident report, and in most systems it is longer than the team believes.
Refresh token rotation and reuse detection
If you issue refresh tokens, rotation is close to mandatory and it is only useful with reuse detection attached.
Rotation means each use of a refresh token invalidates it and returns a new one. The window during which a stolen refresh token is useful shrinks to the interval between refreshes.
Reuse detection is what makes rotation a defence rather than a nuisance. Store the tokens in a family: each new refresh token records the one it replaced, so a chain exists. If a token that has already been used is presented again, there are exactly two explanations. Either the legitimate client’s response was lost in flight and it retried, or someone stole the token and one of the two parties is now using a stale one. You cannot tell which, and the safe response is the same in both cases: invalidate the entire family, which signs out that device and forces a fresh login.
This is the mechanism that turns refresh token theft from a persistent compromise into a detectable event. It is also the reason a naive rotation implementation without families is worse than no rotation at all, because it produces random logouts with no signal attached.
The implementation details that matter:
Handle the race properly. A mobile client with several in-flight requests can genuinely fire two refreshes at once, and a strict implementation logs the user out for no reason. The usual accommodation is a short grace period during which the immediately preceding token still returns the same new token rather than triggering the alarm. Make the grace window small and measured in seconds.
Bind the refresh token to something. A refresh token bound to a client identifier, a device, or ideally a proof-of-possession key is much harder to use after exfiltration. Bearer tokens are usable by whoever holds them, which is the whole problem.
Store hashes, not tokens. A refresh token in your database in plaintext is a credential in your database in plaintext. Hash it. Your store then verifies rather than reveals.
Cap the family lifetime. Rotation without an absolute limit produces sessions that live forever, one refresh at a time. Set an absolute expiry beyond which a full re-authentication is required regardless of activity, separate from the idle timeout.
Log every rotation and every reuse detection. A reuse detection event is one of the highest signal security events your application can produce, and it usually surfaces first as a spike in authentication errors. If your error tracking is where anomalies get noticed, that is where these should land; the error tracking guide covers making that signal legible rather than noise.
Cookies: the flags, and what SameSite actually does
The cookie is the transport, and the attributes are the security model. Getting them right is cheap and getting them wrong is silent.
HttpOnly removes the cookie from JavaScript’s reach. This is the single highest value flag, because it means an XSS bug can act as the user within the page but cannot steal a credential that works from anywhere else afterwards. It is also the reason a session cookie beats a token in localStorage for browser applications, every time.
Secure restricts the cookie to HTTPS. There is no scenario in a modern application where you should omit it.
SameSite controls whether the cookie rides along on requests initiated by other sites, and its behaviour is worth stating precisely because the middle value is subtle:
Strict: the cookie is never sent on any cross-site request, including a plain link from another site. Maximum protection, and a user arriving from an external link lands logged out, which is a real UX cost that catches teams by surprise.Lax: the cookie is sent on top-level navigations that use a safe method, meaning a GET the user initiated by clicking a link, and is withheld on cross-site subresource requests and on cross-site POST. This is whyLaxblocks the classic hidden-form CSRF attack, and why it does not protect an endpoint that performs a state change on GET. If you have a GET endpoint with side effects,SameSitewill not save you.None: the cookie is sent on all cross-site requests and is only accepted alongsideSecure. You need it for genuine cross-origin scenarios, including third party embeds and some SPA-plus-separate-API deployments, and choosing it means you are back to needing explicit CSRF defences.
Domain is a widening decision, not a narrowing one. Setting Domain=example.com makes the cookie available to every subdomain, which means a compromised or untrusted subdomain can read the session cookie. Omitting Domain produces a host-only cookie, which is what you usually want. Cookies also ignore ports and are not isolated by scheme in the way developers expect, so Domain scoping is a weaker boundary than the same-origin policy you are used to.
Prefixes are free integrity. A cookie named with the __Host- prefix is only accepted when it is Secure, has no Domain attribute and has a path of /. That makes it impossible for a subdomain to overwrite it, which is the subdomain cookie-shadowing attack in one line of naming convention.
Keep the session cookie small and keep data out of it. Client-side session stores that serialise state into a signed or encrypted cookie are convenient and have two sharp edges: the data is sent on every request to that origin, and revocation is back to being impossible because the state lives in the client. Signed cookie sessions are fine for small, non-sensitive state; they are not a substitute for a session store when you need to be able to end a session.
Expiry has two dimensions. An idle timeout ends a session after inactivity; an absolute timeout ends it regardless of activity. You want both. Only one is the default in most frameworks.
Session fixation, and the three lines that prevent it
Session fixation is the attack where an attacker causes a victim to authenticate under a session identifier the attacker already knows. The classic form: the attacker gets a session ID from your site, plants it in the victim’s browser through a crafted link or a subdomain-scoped cookie, the victim logs in, your application keeps the same identifier and simply marks it authenticated, and the attacker’s copy is now an authenticated session.
The prevention is one rule: generate a new session identifier whenever the privilege level of the session changes. Concretely, that means at login, at any step-up authentication, at password change, and at account recovery. Copy the data you need into the new session, destroy the old identifier, and set the new cookie.
The same rule at logout means destroying the record on the server, not only clearing the cookie. A logout that only deletes the client-side cookie leaves a valid session in the store that anyone holding the old identifier can still use.
Two related habits belong here. Never accept a session identifier from a URL or a request parameter, because identifiers in URLs end up in browser history, Referer headers, proxy logs and screenshots pasted into support tickets. And bind the session loosely to context: recording the user agent and the network the session was created from, and treating a large change as a reason to re-authenticate rather than to hard-fail, catches some token theft without breaking every user who changes network.
Most maintained frameworks and libraries regenerate on login for you. Most hand-rolled session layers do not, because the person writing it was thinking about login, not about the identifier’s lifecycle.
The session store becomes your highest-QPS service
This is the operational consequence teams meet late, and it is worth designing for at the start.
If every authenticated request validates a session, the store serves every authenticated request. That makes it busier than your database, busier than any individual service, and directly in the availability path of your entire product. When it slows down, everything slows down. When it fails, you are not degraded, you are logged out, globally.
What follows from that:
Pick a store whose failure mode you can accept. An in-memory store like Redis is the usual answer because the latency is right, and the question you must answer is what happens when it restarts. A store with no persistence loses every session and signs out every user at once, which is a self-inflicted outage. A store with persistence has a recovery time you should have measured. Replication adds a failover path and a window during which recently written sessions may be missing.
Never use the default in-process memory store in production. Every framework ships one for development. It does not survive a restart, it does not work behind more than one instance, and it leaks memory in proportion to traffic because nothing expires sessions that were never revisited. It is also the single most common production session bug, because it works perfectly on one developer’s machine.
Cache carefully. Caching session lookups in each application instance cuts load and reintroduces a revocation window equal to your cache TTL. That may be an entirely reasonable trade, but it is the same trade as a JWT and should be made with the same awareness.
Watch the size of what you store. Sessions holding user profiles, permission sets or cart contents multiply the bytes moved on every request. Store an identifier and the minimum needed to authorise; fetch the rest from the source of truth.
Expire aggressively and make sure expiry actually runs. A store with no working eviction path grows without bound. A store with eviction under memory pressure may evict active sessions and sign people out unpredictably, so understand which policy your deployment uses.
Plan for the thundering herd. Any event that invalidates many sessions at once, a deploy that rotates a signing key, a store restart, a bulk revocation, sends every client to your login and refresh endpoints simultaneously. That is a load spike on the one path that must work during an incident. Rate limit it, and make sure your refresh endpoint is not sharing a connection pool with everything else.
Needs first-hand data: Measure your session store’s request rate and p99 latency against your overall request rate for a week, then run a controlled restart of a non-production replica and time how long it takes for the store to return to a warm state. Those two numbers together tell you whether your session layer is an availability risk, and they are the ones missing from every architecture diagram.
SuperTokens

SuperTokens is an open source authentication framework whose session handling is the part most worth the attention. It implements the hybrid model properly out of the box: short-lived access tokens, long-lived refresh tokens with rotation and reuse detection, HttpOnly cookies with correct attributes, and CSRF protection, deployable either self-hosted or as a managed service.
Pros
- Refresh token rotation with reuse detection implemented and documented rather than left as an exercise, which is rare
- Session data lives in your own database when self-hosted, so revocation, session listing and forced logout are all genuinely yours
- Cookie handling, CSRF defences and token lifetimes are correct by default rather than being configuration you must discover
- Session and authentication recipes compose, so adding MFA or passwordless later does not mean rebuilding the session layer
Cons
- Self-hosting adds a core service to the critical path of every authenticated request, with the availability obligation that implies
- The recipe abstraction is comfortable until you need behaviour it did not anticipate, at which point you are reading the source
- Enterprise identity features such as SAML and directory sync are thinner than the full platforms
- Smaller community than the incumbents, so unusual integration problems have fewer existing answers
Best for: Teams who want production-grade session handling with rotation and revocation as a library they self-host, rather than as a property of a vendor’s platform.
Pricing: Open source and free to self-host at infrastructure cost; the managed service is metered on monthly active users with paid add ons for advanced features.
Auth.js

Auth.js, still widely known by its previous name NextAuth, is the default authentication library in the Next.js ecosystem and has expanded to other frameworks. Its session model is a choice you make at configuration time: an encrypted JWT stored in a cookie, or a database session with an adapter for your data store. Understanding which you picked is the most important thing about using it, because the two have entirely different revocation properties.
Pros
- Enormous provider catalogue, so adding an OAuth provider is usually configuration rather than an integration
- The database session strategy gives you real server-side sessions with revocation, using an adapter for the database you already run
- Deeply integrated with the frameworks it targets, so server component and middleware access to the session is a solved problem
- No vendor in the login path at all, and no per user cost of any kind
Cons
- The default JWT session strategy stores state in an encrypted cookie, which means you cannot revoke a session before it expires and many teams do not realise they chose that
- The library has moved through significant API changes across major versions and framework targets, so much of the guidance you find online applies to a version you are not running
- It is a library, not a service: account management screens, MFA and enterprise features are yours to build or buy separately
- Adapter quality varies by database, and the edge cases live in the adapters
Best for: JavaScript teams who want framework-native authentication with no vendor, and who will deliberately choose the database session strategy when revocation matters. Covered further in the Next.js auth guide.
Pricing: There is no vendor and no bill. The cost is the database you run the sessions in and the engineering time to maintain the integration across version changes.
Better Auth

Better Auth is a newer TypeScript-first authentication library built around database sessions as the default rather than as an option, with a plugin architecture covering two-factor authentication, organisations, passkeys and rate limiting. It exists largely because of the friction people hit with the incumbent library, and its design reflects those specific complaints: type safety, an explicit schema, and a session model you can actually reason about.
Pros
- Database sessions by default, so revocation, session listing and forced logout work without a configuration decision you might get wrong
- Strong TypeScript typing across the API surface, which catches a class of integration mistakes at compile time
- Plugin model covers the features that usually force a move to a paid platform, including organisations and two-factor
- Framework agnostic, so it is not tied to the release cadence of a single meta-framework
Cons
- Young project, which means a shorter track record for the component that gates every request and more churn in the API surface
- Smaller ecosystem than the incumbent, so fewer worked examples when something unusual breaks
- Schema management is yours to run, and migrations to the session and user tables are migrations on the tables you can least afford to get wrong
- As a library, everything operational remains your responsibility, including the store behind the sessions
Best for: TypeScript teams starting fresh who want database-backed sessions and typed APIs without adopting a hosted identity vendor.
Pricing: There is no vendor and no bill. You pay in the database that holds the sessions and in the maintenance of a fast moving dependency on your critical path.
Lucia
Lucia was, and in deployed code still is, a minimal session library for TypeScript that deliberately did one thing: manage sessions in your own database with your own schema, leaving login mechanisms to you. Its maintainer has since redirected the project away from being a maintained library and toward being a set of learning resources for implementing sessions directly, which is the single most important fact for anyone considering it today.
Pros
- The smallest useful abstraction in this list: sessions in your database, your schema, no hidden behaviour
- Taught the pattern clearly enough that its documentation is genuinely useful even if you never install it
- Database sessions by construction, so revocation and session listing were never in question
- Minimal surface means minimal supply chain and minimal upgrade risk for code already running on it
Cons
- The project direction has moved away from being a maintained library, which makes it indefensible for a new long-lived deployment
- It was always only sessions, so login mechanisms, MFA and account management were entirely yours
- Existing deployments should plan a path forward, either to another library or to owning the small amount of code involved
- No account management, admin surface or operational tooling of any kind
Best for: Existing users planning their next step, and engineers who want to read a clear implementation of database sessions before writing their own.
Pricing: There is no vendor and no bill. Given the project direction, the real cost to consider is the migration work ahead of you.
Ory Kratos

Ory Kratos is the identity and session component of the Ory stack, an API-first, open source identity server that deliberately holds no session state in your application: it owns identities, self-service flows and sessions, and your services ask it who the caller is, usually through the accompanying proxy and decision API. It is the most infrastructure-shaped option here, and it behaves like a component of a platform rather than a library in your application.
Pros
- Sessions are server-side and first class, with a real API for listing, extending and revoking them across devices
- Clean separation between identity and application, which suits polyglot estates where a JavaScript library is not an option
- Self-hosted with no vendor in the path, and a managed offering if you would rather not run it
- Composes with the rest of the Ory stack for authorisation and OAuth2, covered further in the self-hosted identity provider guide
Cons
- Considerably more operational weight than a library: you are running an identity service, its database, and usually a proxy in front of your applications
- The API-first model means the user facing flows are yours to build, which is a real project before you have a login page
- The conceptual model takes time to absorb, and the split across several Ory components is a common source of early confusion
- Overkill for a single application that needs sessions and nothing else
Best for: Polyglot or service-oriented estates that need one identity and session service outside the applications, and can staff running it.
Pricing: Open source and free to self-host at infrastructure and operational cost; the managed network is metered on active identities with feature tiers.
Clerk

Clerk is the managed option in this list. Sessions, tokens, refresh handling, device management and the user-facing screens for all of it are the vendor’s problem, and the session appears in your application through SDKs and middleware. For teams whose priority is shipping rather than owning this layer, that is the entire value proposition and it is a legitimate one.
Pros
- Session lifecycle, rotation and revocation are implemented and operated by the vendor, including the device list users can act on
- Active session management UI ships with the product, which is a feature most teams never get around to building
- Middleware and server-side helpers make session checks straightforward in the frameworks it targets
- Organisation-aware sessions, so multi-tenant applications get tenant context without inventing it
Cons
- Your session validation depends on a third party being reachable, which is a new availability dependency for every authenticated request path that is not purely local token verification
- Session and user data live with the vendor by design, which makes migration a project and raises residency questions in enterprise deals
- Per monthly active user pricing scales with your user base, which is painful for products with many low value users
- Customisation beyond the supported surface means dropping to lower level APIs and losing much of the benefit
Best for: JavaScript product teams who want session management and its UI to be somebody else’s operational problem.
Pricing: Free entry tier with per monthly active user pricing above it and separate charges for organisation and enterprise features.
express-session
express-session is the reference implementation of the boring answer, and the boring answer is frequently correct. It issues an opaque session identifier in a cookie, stores the session server-side through a pluggable store, and exposes the session as an object on the request. It has been in production across the Node ecosystem long enough that its sharp edges are thoroughly documented.
Pros
- Opaque server-side sessions, so revocation, session listing and immediate permission changes are all trivial by construction
- Pluggable stores mean Redis, PostgreSQL or anything else you already operate, with no new datastore in your architecture
- Small, well understood surface with a long track record on the path that matters most
- Complete control over cookie attributes, expiry semantics and the session record’s contents
Cons
- The default in-memory store is unusable in production and is the most common session bug in the Node ecosystem, because it works perfectly in development
- It gives you sessions and nothing else: login, MFA, CSRF protection and account management are all separate decisions
- Rolling your own around it means you own session regeneration on login, absolute versus idle expiry, and every cookie flag, and the failures are silent
- Express-centric, so it does not travel to other frameworks or runtimes
Best for: Node applications with a server-rendered or first-party frontend where an opaque session cookie against a store you already run is exactly the right amount of machinery.
Pricing: There is no vendor and no bill. The cost is the store you point it at and the discipline to configure the cookie and regeneration behaviour correctly.
How to choose
Work through these in order. The first two eliminate most of the list.
Does your application need to revoke a session immediately? If an account takeover, an employee termination or a permission change has to take effect on the next request, you need server-side sessions. That rules out cookie-stored JWT strategies and makes the database session option mandatory where a library offers both. Answer this before you look at any feature list, because it is the property everything else is negotiable around.
Is the credential going somewhere you do not control? A browser talking to your own backend wants an HttpOnly opaque cookie. A token crossing into services or partners you do not own wants a short-lived signed token with a proper audience claim. Many systems need both, and the mistake is picking one mechanism and forcing it into the other role.
Do you want to operate this layer? A library means no vendor, no per user bill and total control, plus you own the store, the rotation logic and the pager. A managed provider means someone else operates it and your session checks depend on them being up. Both are defensible; what is not defensible is choosing the library and then not implementing rotation, regeneration and revocation.
How many users, and how valuable is each one? Per monthly active user pricing on the session layer is what pushes high-volume, low-value products toward libraries. The open source authentication guide covers what that self-hosted path looks like in practice.
| Option | Session model | Picks itself when |
|---|---|---|
| SuperTokens | Server-side, rotation and reuse detection | You want correct rotation without implementing it yourself |
| Auth.js | Cookie JWT or database, your choice | Framework-native auth with the database strategy chosen deliberately |
| Better Auth | Database sessions by default | TypeScript-first greenfield that wants revocation without configuration |
| Lucia | Database sessions, minimal | You are reading it to learn the pattern, or planning your move off it |
| Ory Kratos | Server-side, external identity service | Polyglot estate needing one identity service outside the apps |
| Clerk | Managed by the vendor | You want this layer to be somebody else’s operational problem |
| express-session | Opaque cookie, pluggable store | A Node app where a session cookie and Redis is the right answer |
Whatever you choose, four checks take an afternoon and catch most of what goes wrong. Confirm the session identifier is regenerated at login. Confirm your cookies carry HttpOnly, Secure and a deliberate SameSite, and consider the __Host- prefix. Confirm refresh token reuse invalidates the family rather than just the token. And measure how long a disabled user can keep making authenticated requests, because that number is your real revocation window and it belongs in your incident runbook, not in a comment. The wider platform decision sits in the authentication provider hub.
Frequently asked questions
Should I use JWTs or server-side sessions?
Server-side sessions for a browser application talking to your own backend, because you get immediate revocation and the cookie can be HttpOnly, and the store lookup is cheap. JWTs where a credential crosses a boundary that cannot query your store, such as service-to-service calls or third party APIs. Most real systems use both: a short-lived signed access token for services and a server-side record that is the thing you can actually revoke. The decisive question is not performance, it is whether you need to end a session before it expires.
How do I actually revoke a JWT?
You cannot, in the sense of making a signed token invalid on its own terms. What you can do is add state back. Keep access tokens short and revoke at the refresh exchange, which makes your revocation window equal to the access token lifetime. Or maintain a denylist of token identifiers checked on every request, which gives you true revocation and reintroduces the lookup you were avoiding. Or carry a version counter in the token and compare it to the user’s current counter, which revokes everything for a user cheaply but cannot target one session. Pick one deliberately and write the resulting window into your incident response plan.
What does SameSite=Lax actually protect against?
It stops the browser from attaching your cookie to cross-site subresource requests and to cross-site POST submissions, which is the shape of the classic CSRF attack using a hidden form or an image tag. It still sends the cookie on a top-level navigation with a safe method, meaning a user clicking a link from another site arrives logged in, which is why it is usable as a default. The gap it leaves is any endpoint that performs a state change on GET, because Lax will happily send the cookie there. Fix the endpoint rather than the cookie.
Where should I store a token in a browser?
In an HttpOnly cookie, set by the server, with Secure and a deliberate SameSite. localStorage and sessionStorage are readable by any JavaScript running on the page, so a single cross-site scripting bug anywhere, including in a dependency, exfiltrates a credential that then works from the attacker’s own machine. An HttpOnly cookie does not eliminate the impact of XSS, since the attacker can still act inside the page, but it prevents the credential from leaving it, and that is a meaningful difference in blast radius.
Do I need CSRF protection if I use SameSite cookies?
SameSite=Lax removes most of the classic attack surface, and for a first party application with no cross-site posting it is close to sufficient on modern browsers. You still want explicit CSRF tokens if you set SameSite=None for a cross-origin setup, if you have GET endpoints with side effects, or if you need to support clients where you cannot rely on the attribute being enforced. The cost of keeping the token check is low and the failure mode of removing it is a silent one.
Related reading
- Best authentication providers for developers — the platform decision that determines how much of this layer you own.
- Best auth solutions for Next.js — middleware, server components and the session strategy choice in that ecosystem.
- Best API authentication tools and patterns — tokens that cross a boundary you do not control, and revocation propagation.
- Best passkey and WebAuthn providers — what happens before the session exists, and why recovery must revoke it.
- Best self-hosted identity providers — running the identity and session service yourself, including key rotation.
- Best open source authentication solutions — the operational reality of owning this layer rather than buying it.