SSO and OAuth 2.0 in Practice
Authentication and authorization underpin every product, yet many developers remain unclear about how OAuth, OIDC, JWT, and sessions relate. This article explains sessions vs. JWTs, OAuth 2.0 flows, OIDC's authentication layer, SSO implementation, token storage, and security so the concepts no longer feel tangled.
Key Points
- Topic Focus: React、DevOps
- Suitable for: indie hackers, full-stack engineers, AI application developers
- Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
After building authentication systems for more than five projects, I noticed a pattern: 90% of developers can get "Sign in with Google" working, but fewer than 10% can clearly explain the difference between OAuth and OIDC, why a JWT cannot simply be "logged out," or exactly what is wrong with putting an access token in localStorage. These concepts are confusing not because they are difficult, but because there are too many names, the layers overlap, and every name carries historical baggage.
This article breaks down the most confusing pairs of concepts in modern web authentication by layer: authentication vs. authorization, sessions vs. JWTs, OAuth vs. OIDC, and single sign-on (SSO). The code focuses on Next.js and React scenarios, but the principles apply to any web stack.
1. First, Distinguish "Authentication" from "Authorization"
Many people use the terms interchangeably, but they address two completely different questions:
- Authentication (abbreviated AuthN): Answers "Who are you?" Account passwords, facial recognition, fingerprints, and SMS verification codes are all forms of authentication. The system verifies whether your identity claim is valid.
- Authorization (abbreviated AuthZ): Answers "What are you allowed to do?" You are already signed in and your identity is known; the system determines whether you may read a file, call an API, or access an admin console.
Authentication: Are you Zhang San? → Show an ID card
Authorization: Can Zhang San delete this file? → Check the permissions table
Authentication comes first, followed by authorization. The order is fixed. HTTP 401 means "not authenticated or authentication has expired" (sign in first), while 403 means "authenticated but not authorized" (you are signed in, but you cannot delete someone else's article).
This distinction is not a philosophical exercise. OAuth 2.0 was originally designed only for authorization, not authentication, yet many people use it as a "login protocol" and run into numerous problems. We will examine this in Section 3.
2. Sessions vs. JWTs: The Trade-Off Between Stateful and Stateless Authentication
After authentication, the system must remember that "this request came from Zhang San." There are two mainstream approaches: sessions and JWTs. Your choice largely determines the complexity of the entire architecture.
Sessions: The Server Stores State, and the Client Carries Only an ID
The flow:
1. The user sends POST /login (username and password)
2. The server verifies the credentials → stores { sessionId: "abc", userId: 42, ... } on the server
3. The response includes Set-Cookie: sessionId=abc; HttpOnly; Secure; SameSite=Lax
4. The browser automatically includes the cookie in subsequent requests, and the server uses sessionId to retrieve the user
Server-side storage can be memory for a single instance, Redis as the standard distributed option, or a database, which is slow and not recommended. The key point is that state lives on the server, while the client holds only a key.
The advantage is fast revocation: delete the session on the server, and the user is signed out the next second. Password changes, forced sign-outs, and account suspensions are all easy to implement. The disadvantage is that horizontal scaling requires shared session storage. Multiple servers must use the same Redis instance; otherwise, a request routed to server A cannot find a session created by server B.
JWTs: Stateless, Self-Contained Tokens
A JWT is a signed string that looks like xxxxx.yyyyy.zzzzz. Its three sections are the Header, Payload, and Signature. The Payload directly contains values such as userId, expiration time, and roles. After verifying the signature with a key, the server trusts the contents without querying any storage.
1. The user sends POST /login
2. The server verifies the credentials → signs a JWT whose payload contains { userId: 42, exp: ... }
3. The JWT is returned to the client (in a cookie or localStorage)
4. Subsequent requests include the JWT; the server verifies its signature → trusts the payload and completes authentication
The advantages are statelessness and easy horizontal scaling. Any server with the same key can verify the signature, making JWTs a natural fit for microservices and Serverless environments. The disadvantage is difficult revocation. Once issued, a JWT remains valid until it expires, so the server cannot simply "delete the session." This is the greatest cost of JWTs.
How to Choose
My rule of thumb:
- Monoliths / admin systems: Prefer sessions. These systems often need revocation, such as applying permission changes immediately or forcing users offline, and Redis is sufficient.
- Microservices / API gateways / Serverless: JWTs are more convenient. However, always combine a short-lived access token with a long-lived refresh token (see Section 6); never let one token remain valid for a year.
- Scenarios requiring immediate JWT revocation: Maintain a blacklist or token version number and query Redis during verification. That query removes half the benefit of JWT "statelessness," however. If strong revocation is truly required, use sessions instead.
3. OAuth 2.0: It Is an "Authorization Framework," Not a "Login Protocol"
The core problem OAuth 2.0 solves is third-party authorization.
For example, suppose you use a third-party photo-printing service that needs to read photos from your Google Photos library. You should not give that service your Google username and password. OAuth 2.0 lets you authorize the third party to read your photos on Google's own page. The third party receives an access token with limited permissions, not your password.
User ──> Third-party application ──> "I want to access your Google Photos library"
↓
Redirect to Google's authorization page
(the user signs in to Google)
↓
The user clicks "Allow" → Google gives the third party an access token
↓
The third party uses the token to call the Google API and read photos
The third-party application never touches the user's Google password during this entire process. That is the essence of OAuth 2.0.
Four Flows, but You Only Need to Remember the Authorization Code Flow
RFC 6749 defines four authorization flows, or Grant Types, for different scenarios:
- Authorization Code — The most common flow and the standard choice for web and mobile applications. It is explained below.
- Implicit — Originally designed for browser-only SPAs, but now deprecated and replaced by PKCE.
- Password Credentials — The user gives the password directly to the client in exchange for a token. Only for highly trusted first-party apps; not recommended.
- Client Credentials — Machine-to-machine (M2M) communication, where one backend service calls another without any "user" participating.
Authorization Code Flow: Why Token Exchange Takes "Two Steps"
Step 1: The browser navigates to the authorization server, where the user signs in and grants permission
→ The authorization server redirects to your callback URL with an authorization code
(the code is short-lived and single-use, so it is of little use by itself if leaked)
Step 2: Your backend exchanges the code + client_secret for an access token
(this step occurs between the backend and the authorization server; the secret never passes through the browser)
Why add this step instead of returning the token directly? Because the browser is an untrusted channel. Returning a token in the redirect URL can expose it through browser history, Referer headers, or logs. A code is short and single-use. Even if stolen, it can only be exchanged once and requires the client_secret, which exists only on the backend and is never available to the frontend.
SPAs and Mobile Apps Use PKCE
A browser-only SPA has no backend where it can store a client_secret, because anyone can inspect frontend code. The solution is PKCE (Proof Key for Code Exchange). The client generates a code_verifier, derives a code_challenge, and sends the challenge with the authorization request. When exchanging the code for a token, it sends the code_verifier, and the authorization server verifies that the hash matches. This replaces a static secret with a dynamic secret, so the frontend stores no permanent secret. In 2026, all public clients, including SPAs and mobile apps, must use PKCE. OAuth 2.1 makes this mandatory.
Key Insight: OAuth 2.0 Is Not Authentication
OAuth 2.0 gives you an access token representing that "this application may perform certain actions on the user's behalf," but it does not directly tell you who the user is. You may assume that obtaining the token identifies the user, but that is only a side effect. Using OAuth directly as a login mechanism causes strange bugs: the same user may have inconsistent identities across clients, sign-out states may not synchronize, and email addresses may not be reliably available. For "login," you need the next layer: OIDC.
4. OIDC: Adding an "Authentication" Layer on Top of OAuth
OIDC (OpenID Connect) is an authentication extension to OAuth 2.0. It adds an ID Token to the authorization flow.
The ID Token is itself a JWT and directly contains user identity information: sub (unique user ID), email, name, iss (issuer), aud (audience), and exp (expiration). After receiving the ID Token, the client can verify its signature and validate iss/aud/exp to reliably determine who the user is. This is the actual mechanism behind "Sign in with Google."
OAuth 2.0 Authorization Code Flow + OIDC:
scope=openid profile email ← Key requirement: include openid
↓
Receive code in callback → backend exchanges it for three tokens:
- access_token (for API calls; authorization)
- id_token (JWT; authentication — identifies the user)
- refresh_token (for renewal)
Therefore, an accurate description of "Sign in with Google" is: use OIDC based on the OAuth 2.0 Authorization Code Flow + PKCE, then extract the user's identity from the ID Token. The "Sign in" buttons from Google, GitHub, and Microsoft all use OIDC behind the scenes.
Three non-negotiable implementation rules:
- The scope must include
openid. Without it, you are using plain OAuth and will not receive an ID Token. - The client must verify the ID Token's signature and validate
iss(it was genuinely signed by Google),aud(it was genuinely issued for your client_id), andexp(it has not expired). Do not blindly trust and use JSON returned by the server. The ID Token's security comes from its signature, not the transport layer. - After completing OIDC, your own system must still manage its session normally, using either your own signed JWT or a session. Do not use Google's access token as a long-term credential for your system. Google's token is for calling Google APIs, not your own system.
5. SSO: Implementing One Login Across Multiple Systems
SSO (Single Sign-On) is a user-experience requirement: the user signs in once, and every related system recognizes them. For example, after signing in to Google, you do not need to sign in again to Gmail, YouTube, or Drive.
SSO is not a specific protocol but an architectural pattern. The implementation model is a central authentication service + token transfer:
┌──────────────────────────┐
│ Authentication Center │ ← The user signs in here
│ (IdP) │
│ Issues a ticket/token │
└────────────┬─────────────┘
│
┌────────────┼────────────┐
▼ ▼ ▼
┌────────┐ ┌────────┐ ┌────────┐
│System A│ │System B│ │System C│
└────────┘ └────────┘ └────────┘
There are two mainstream implementations:
1. CAS model (traditional enterprises): A user visits System A and is not signed in → the user is redirected to the authentication center to sign in → the authentication center returns a ticket to System A → System A exchanges the ticket with the authentication center for user information. When the user visits System B, the authentication center already has a global session, so it issues a ticket without asking for the password again. SAML is a representative protocol for this model and is commonly used for internal enterprise SSO.
2. OIDC model (modern internet services): Use an OIDC Provider (Identity Provider) as the central authentication service. Each business system acts as an OIDC client, and every login goes through OIDC. Once you sign in to the OIDC Provider, all clients share that login state because they use the same Provider session. Auth0, Authing, Keycloak, and Logto all follow this model.
Implementation recommendations:
- Small teams / new projects: Do not build SSO yourself. Integrate Auth0 / Logto / Authing / a self-hosted Keycloak deployment. SSO looks simple, but it has enough edge cases, including synchronized sign-out, token refresh, and cookie sharing across multiple domains, to make you question your life.
- A self-built SSO system must solve three problems:
- Cross-domain cookies: Systems on different root domains cannot share cookies. Use redirects with tickets in URL parameters, or transfer tokens through a frontend iframe.
- Unified sign-out (SLO, Single Logout): When the user signs out of System A, every system must invalidate the session. CAS has a dedicated protocol, while OIDC provides RP-initiated Logout.
- Consistent session renewal: Do not leave System A with a valid token while System B's token has already expired, creating a fragmented experience.
6. Where to Store Tokens and How to Use Them Securely
This is the most common source of mistakes. First, distinguish between two token types:
- access token: Short-lived (15 minutes to 1 hour) and used to call APIs. If leaked, the exposure window is short.
- refresh token: Long-lived (several days to several dozen days), used only to obtain a new access token after expiration, and never included in business requests. It poses a higher risk if leaked and requires stronger protection.
This "short access + long refresh" design is an engineering compromise: using refresh tokens for renewal enables frequent use of access tokens while preserving both security, because leaked access tokens have limited impact, and usability, because users do not need to sign in repeatedly.
Where Should Tokens Be Stored? httpOnly Cookies Are Safest
✗ localStorage / sessionStorage — One XSS attack can steal the token: game over
✗ Regular cookie (without HttpOnly) — JavaScript can read it, so XSS can still steal it
✓ httpOnly + Secure + SameSite cookie — JavaScript cannot read it, and the browser sends it automatically; this is the standard
Never store an access token in localStorage. An XSS vulnerability in any third-party npm package can steal the token and impersonate the user when calling any API. This is not a theoretical risk; npm supply-chain attacks occur in the real world every year.
The cost of an httpOnly cookie is that it does not inherently prevent CSRF, because browsers attach cookies automatically. Mitigations include SameSite=Lax/Strict, which omits cross-site cookies by default and blocks most CSRF attacks; backend validation of Origin/Referer; and secondary confirmation or a separate CSRF token for sensitive operations such as transfers and account deletion.
Refresh Tokens Require Even More Care
A refresh token has a long lifetime, so a leak is equivalent to exposing a long-term key. Additional measures include:
- Store it only in an httpOnly cookie, never in localStorage.
- Rotation: Whenever a refresh token is used to obtain a new access token, issue a new refresh token at the same time and invalidate the old one. If the token is stolen, only the legitimate user or the attacker can use it successfully; the next attempt by the other party exposes the theft.
- Bind it to a device fingerprint / IP range in high-security scenarios.
7. Lessons from Real Mistakes: JWT Revocation, Token Leaks, and Redirect Validation
Pitfall 1: Assuming a JWT can be "logged out." A JWT is stateless, so the server has no "delete" button. Deleting the token in the frontend merely makes the client "forget" it; the token itself remains valid until it expires. If it was stolen, the attacker can still use it. A real "logout" requires a server-side blacklist, which restores statefulness, or waiting for expiration. Therefore, JWTs are inherently weak for password changes, forced sign-outs, and account suspensions. You must combine them with a blacklist and a short exp, or simply use sessions.
Pitfall 2: Failing to validate redirect_uri. In the OAuth flow, the authorization server redirects to redirect_uri. Without strict allowlist validation, an attacker can register a similar-looking phishing domain, trick the server into sending the code there, and exchange it for your token. Fix: redirect_uri must exactly match an allowlist entry, not a prefix or wildcard subdomain, and the authorization server must enforce the check.
Pitfall 3: Open redirects. After your callback processes the code, it may redirect according to a query parameter such as ?next=/home. Without validation, this becomes an open redirect vulnerability. An attacker can send ?next=https://evil.com, causing the user to be sent to a phishing site after signing in. Fix: Allow only relative paths in next, or enforce a strict allowlist.
Pitfall 4: Omitting the state parameter → CSRF. At the start of the Authorization Code Flow, the client generates a random state value and includes it in the redirect, then verifies the same state in the callback. Without it, an attacker can forge a callback that binds the victim to someone else's account, which is especially serious in account-linking flows. Always include state, and always validate it.
Pitfall 5: Using an access token as a session. The access token Google gives you is for calling Google APIs; it is not a session credential for your own system. After completing OIDC, use the identity information in the ID Token to issue your own session/JWT. That is your system's authenticated session. Mixing the two causes strange bugs such as "the Google token expired, so my system thinks the user signed out."
Summary
- Authentication (AuthN) answers "Who are you?" while authorization (AuthZ) answers "What are you allowed to do?" A 401 means the user is not authenticated; a 403 means the user lacks permission. OAuth 2.0 was designed only for authorization, not login.
- Sessions are stateful and easy to revoke; JWTs are stateless and easy to scale. Difficult revocation is JWTs' fundamental weakness. Short-lived access tokens plus long-lived refresh tokens are the engineering compromise; never let one token remain valid for a year.
- OAuth 2.0 is an authorization framework; OIDC adds an authentication layer, through the ID Token, and enables actual "Sign in with Google" behavior. Authorization Code Flow + PKCE is the only recommended approach for web and mobile clients in 2026; the Implicit Flow is deprecated.
- SSO is not a protocol; it is an architecture based on a central authentication service and token transfer. For new projects, do not build it yourself; use Auth0 / Logto / Keycloak.
- Store tokens in httpOnly cookies, not localStorage; refresh tokens must use rotation; failing to validate
redirect_uri,state, andnextcreates three separate vulnerabilities.
To sum it up: keep authentication and authorization concepts properly layered, store tokens in the right place, and validate parameters strictly. Get these three things right, and you will avoid 90% of security pitfalls. The remaining 10% are bugs in the wheel you reinvented, so use a mature solution whenever possible instead of building your own.
Related
Related Content
Article
Is Next.js App Router a Good Fit for a Technical Community?
Technical trends, engineering practices, and independent development perspectives centered on Next.js and React.
Article
SEO Design for a Content Community with Next.js
Technical trends, engineering practices, and an indie developer's perspective on Next.js and TypeScript.
Article
The Engineering Value of TypeScript in AI Application Frontends
Technical trends, engineering practices, and an independent developer perspective on TypeScript and React.
Article
What Value Do Server Components Offer Content Sites?
Technical trends and engineering practices around Next.js and React, from an independent developer's perspective.