Backend
Authentication & Authorization: Sessions, JWT & OAuth2
Why authentication and authorization aren't the same thing, and how sessions, JWTs, and OAuth2 each solve a different piece of the puzzle.
Ask ten engineers to explain authentication and at least a few will drift into talking about permissions, roles, or API scopes. That drift is the tell. Authentication and authorization sound similar, get abbreviated the same way ("auth"), and are usually implemented in adjacent files — but they answer completely different questions, and conflating them is one of the fastest ways to design a system with a security hole in it.
Authentication vs. authorization
Authentication answers "who are you?" — it's the process of proving an identity, typically with a password, a magic link, a fingerprint, or a token that was issued after one of those checks succeeded. Authorization answers "what are you allowed to do?" — given that you are who you say you are, can you delete this record, view this dashboard, or charge this card? A system can authenticate you perfectly and still be wrong about authorization, and vice versa: a request can carry a perfectly valid permission check while authenticating the wrong person entirely, if the two are wired together carelessly.
A useful shorthand
Authentication happens once, at login. Authorization happens on every single request after that. If your code checks user.role === "admin" inside a route handler, that's authorization — even though it's often bundled into the same middleware that also authenticates the request.
Session-based authentication
The classic approach: after a successful login, the server creates a session record — in memory, in a database, or (far more common in production) in Redis — and hands the client an opaque, randomly generated session ID inside a cookie. The cookie itself carries no meaning; it's just a lookup key. Every subsequent request sends the cookie back, the server looks up the session, and either finds a valid user or doesn't.
// Session-based: server owns the state
POST /login
-> verify credentials
-> create session record in Redis: { userId, expiresAt }
-> respond Set-Cookie: sid=<opaque-random-id>; HttpOnly; Secure
GET /me
-> read cookie "sid"
-> session = redis.get(sid) // stateful lookup, every request
-> if (!session) return 401
-> return userFor(session.userId)The appeal of sessions is how easy they are to reason about. Revocation is trivial — to log a user out, or to kill every active session after a password reset, you just delete the row. There's no window where a stale credential keeps working, because the server is the single source of truth for whether a session is still valid.
The cost shows up once you scale horizontally. A session stored in one app server's memory is invisible to every other app server, so you either need sticky sessions (routing a given user's requests back to the same instance, which complicates load balancing and failover) or a shared session store like Redis that every instance can query. Either way, checking "is this user logged in" now requires a network hop on every request.
JWT-based authentication
A JSON Web Token flips the model: instead of an opaque pointer to server-side state, the token itself carries the state. It's a signed, base64-encoded blob containing claims — the user's ID, their role, an expiry timestamp — and a signature that proves the server issued it and it hasn't been tampered with. Verifying a JWT means checking a cryptographic signature, not querying a database, so the server can stay fully stateless.
// JWT-based: server verifies, doesn't store
POST /login
-> verify credentials
-> token = jwt.sign({ sub: userId, role: "admin" }, SECRET, { expiresIn: "15m" })
-> respond { accessToken: token }
GET /me
-> read "Authorization: Bearer <token>"
-> payload = jwt.verify(token, SECRET) // no database round-trip
-> if invalid or expired, reject
-> return userFor(payload.sub)That statelessness is genuinely valuable at scale: any app server that holds the signing secret (or the public key, for asymmetric signing) can verify a token instantly, with no shared store and no network round-trip. It's also exactly where the trade-off bites. A session can be revoked by deleting a row; a JWT, once issued, stays valid until it expires — full stop. If a token is stolen, or a user's access needs to be pulled immediately, there's no built-in way to invalidate it early.
Revocation is the real trade-off
Teams work around this with a token blocklist checked on every request, or by keeping access tokens short-lived (minutes, not days) and pairing them with a longer-lived refresh token that is stored server-side and can be revoked. Both approaches reintroduce some server-side state — which is worth admitting up front rather than discovering under incident pressure. "Fully stateless" JWTs and "instantly revocable" are, in practice, in tension with each other.
OAuth2: delegated authorization, not login
OAuth2 gets misfiled constantly, so it's worth being precise: OAuth2 is a delegated authorization framework. It lets a third-party application get limited, scoped access to act on a user's behalf — "let this calendar app read (but not delete) your events" — without that application ever seeing the user's actual password. The user authenticates directly with the provider (Google, GitHub, whoever owns the data), the provider issues a scoped access token to the third-party app, and that token is what gets used for subsequent API calls, not the user's credentials.
Notice what's conspicuously absent from that description: anything about establishing who the user is. OAuth2 was designed to answer "can this app read my calendar," not "who is logging in" — which is exactly why bolting "Sign in with Google" directly onto raw OAuth2 was always a slightly awkward fit. That gap is what OpenID Connect (OIDC) fills: it's an identity layer built on top of OAuth2 that adds a standardized id_token (itself a JWT) asserting who the user is. In practice, "OAuth2 login" almost always means OAuth2 plus OIDC working together, even though the two get referred to interchangeably.
Which one to reach for
For a first-party login system — your own app, your own users, your own database — sessions or short-lived JWTs paired with refresh tokens both work well; sessions if you want simple, immediate revocation and don't mind the shared store, JWTs if you need to stay stateless across many services and can tolerate a short window of non-revocability. Reach for OAuth2 (with OIDC) specifically when the problem is either "let users log in with an account they already have elsewhere" or "let this app access a user's data on another service" — that's the problem OAuth2 was actually built to solve, and re-implementing it yourself for that use case is rarely a good use of anyone's time.
Key takeaways
- Authentication proves who you are; authorization decides what you're allowed to do — keep the two mentally separate even when the code lives together.
- Sessions keep state on the server and hand the client an opaque ID — easy to revoke, but requires a shared store or sticky sessions once you scale out.
- JWTs keep state in the signed token itself, enabling stateless verification — at the cost of revocation being hard until the token naturally expires.
- OAuth2 is delegated authorization ('let this app act on my behalf'), not authentication — it never involves the third-party app seeing a password.
- OpenID Connect adds the identity layer ('who is this user') on top of OAuth2; 'login with X' is really OAuth2 + OIDC together.
- Default to sessions or short-lived JWTs + refresh tokens for your own login; reach for OAuth2/OIDC for third-party login or cross-service data access.
