A practical comparison of server sessions, JSON Web Tokens and OAuth 2.0 with PKCE, with clear advice on which one fits a web app, a mobile app or an API.

Authentication is the part of an application where a small mistake becomes a big incident. It is also where teams spend a lot of time debating tools. This article cuts through the debate: what each approach is, where it fits, and the defaults we would choose.
Authentication and authorisation
Two words that get mixed up:
- Authentication answers "who are you?"
- Authorisation answers "what are you allowed to do?"
Everything below is about the first. Get it right, and then enforce authorisation on the server for every request.
Option 1: Server-side sessions
The server creates a random session ID after login, stores the session data (in memory, Redis or a database), and sends the ID to the browser in a cookie. On each request, the server looks the session up.
Boring wins: server session + HttpOnly cookie.
Set-Cookie: sid=9f2c...; HttpOnly; Secure; SameSite=Lax; Path=/
Strengths
- You can end a session instantly, for a logout, a password change or a suspected theft.
- The browser never holds anything that can be read by JavaScript when the cookie is
HttpOnly. - Simple to reason about.
Weaknesses
- Needs shared session storage once you run more than one server.
- Less natural for mobile apps and third-party API clients.
Option 2: JSON Web Tokens (JWT)
A JWT is a signed token that carries claims such as the user ID and an expiry. The server can verify it without looking anything up.
JWTs are hard to revoke. Keep them short-lived.
Strengths
- Stateless verification, which suits service-to-service calls.
- Easy to pass between different services and domains.
Weaknesses
- Revocation is hard. A valid token stays valid until it expires, unless you keep a deny list, which brings back the state you wanted to avoid.
- Storage is risky. Keeping a token in
localStorageexposes it to any cross-site scripting bug. - Mistakes in verification, such as accepting the wrong algorithm, have caused serious breaches.
JWTs are a good tool for short-lived tokens between services. They are a poor default for keeping a person logged in to a website.
Option 3: OAuth 2.0 and OpenID Connect
OAuth lets your app obtain access without ever seeing the user's password, by delegating login to an identity provider such as Google, Microsoft or your own company login. OpenID Connect adds a standard way to get the user's identity on top of it.
For browser and mobile apps the recommended flow is authorization code with PKCE:
sequenceDiagram
actor U as User
participant A as App
participant AS as Auth server
participant API as API
U->>A: Start login
A->>A: Generate verifier
A->>A: Create code challenge
A->>AS: Authorization request
AS->>U: Request authentication
U->>AS: Authenticate
AS-->>A: Return authorization code
A->>AS: Send code and verifier
AS->>AS: Verify PKCE challenge
AS-->>A: Return access token
A->>API: Request with token
API-->>A: Return protected data
A-->>U: Show application
PKCE protects the code exchange, so that a stolen authorisation code is useless without the secret that only your app holds.
Use this when you want "Sign in with Google" or single sign-on for business customers, or when you are building the identity provider yourself and many apps will rely on it.
Which one should you pick
| Situation | Recommended |
|---|---|
| A web app where users log in to your own site | Server sessions with an HttpOnly cookie |
| "Sign in with Google" or customer SSO | OAuth 2.0 / OpenID Connect, with PKCE |
| Mobile app talking to your API | OAuth with PKCE, short-lived access token plus a refresh token in secure storage |
| Service-to-service calls inside your system | Short-lived JWTs or mutual TLS |
| Public API for developers | API keys for identification, OAuth for acting on behalf of users |
For most business web applications, boring server sessions are the right answer.
Whatever you choose, do these things
- Hash passwords properly with Argon2id or bcrypt. Never store them in a reversible form or with a fast hash such as plain SHA-256.
- Offer multi-factor authentication, at least for admin accounts. An authenticator app or a passkey is much stronger than SMS.
- Rate-limit login and password reset endpoints, and lock or slow down after repeated failures.
- Use HTTPS everywhere and mark cookies
Secure. - Set
SameSiteon cookies and add CSRF protection for state-changing requests. - Rotate the session ID after login to prevent session fixation.
- Give password reset links a short life and make them single-use.
- Log authentication events, such as sign-ins, failures and resets, so you can investigate.
Common mistakes
- Putting long-lived tokens in
localStorage. - Trusting the client to say who it is, for example reading a
userIdfrom the request body. - Writing your own cryptography or password storage.
- Checking permissions only in the interface and not on the server.
- Leaving default or shared admin accounts in place.
Conclusion
You do not need the most modern option. You need the one that matches your clients, can be revoked when something goes wrong, and avoids keeping secrets where scripts can read them. For a website, that is usually server sessions. For delegated login, it is OAuth with PKCE. For internal service calls, it is short-lived tokens.
Hash with Argon2id or bcrypt. Never a fast hash.