Skip to main content
{/}codefunc
GuidesJuly 7, 2026 · 8 min read

Frontend Authentication: OAuth 2.0, JWT, and Session Tokens Explained

A practical guide to authentication for frontend developers — understanding OAuth flows, JWT structure, session cookies, and implementing secure auth in React applications.

By codefunc

authenticationoauthjwtsessionssecurityfrontendreact

Authentication is how your app knows who the user is. Authorization is what they can do. Frontend developers need to understand both — not to build auth systems from scratch, but to integrate them correctly. Misunderstanding token storage, OAuth flows, or session handling leads to security vulnerabilities.

Bottom line: Use OAuth 2.0 with PKCE for third-party login (Google, GitHub). Store tokens in HTTP-only cookies when possible, not localStorage. Understand that JWTs are signed but not encrypted — don't put secrets in them. For most apps, use an auth library (NextAuth, Auth0, Clerk) rather than implementing from scratch.

Authentication methods overview

Method Best for Storage Security
Session cookies Traditional web apps Server-side High
JWT in cookies SPAs with same-origin API HTTP-only cookie High
JWT in memory SPAs with cross-origin API JavaScript variable Medium
JWT in localStorage Never recommended localStorage Low
OAuth 2.0 Third-party login Varies by flow High

JWT (JSON Web Tokens)

JWTs are self-contained tokens that carry user information.

JWT structure

A JWT has three parts separated by dots:

header.payload.signature
eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.
eyJzdWIiOiIxMjM0NTY3ODkwIiwibmFtZSI6IkpvaG4iLCJpYXQiOjE1MTYyMzkwMjJ9.
SflKxwRJSMeKKF2QT4fwpMeJf36POk6yJV_adQssw5c

Header:

{
  "alg": "HS256",
  "typ": "JWT"
}

Payload:

{
  "sub": "1234567890",
  "name": "John",
  "iat": 1516239022,
  "exp": 1516242622
}

Signature: HMAC-SHA256 of header + payload with secret key.

Use the JWT Decoder to inspect tokens during development.

JWT in React

// Storing JWT (in memory - preferred for SPAs)
let accessToken: string | null = null;

export function setToken(token: string) {
  accessToken = token;
}

export function getToken() {
  return accessToken;
}

// Using with fetch
async function fetchProtectedData() {
  const response = await fetch('/api/data', {
    headers: {
      'Authorization': `Bearer ${getToken()}`,
    },
  });
  return response.json();
}

JWT with Axios interceptor

import axios from 'axios';

const api = axios.create({
  baseURL: '/api',
});

api.interceptors.request.use((config) => {
  const token = getToken();
  if (token) {
    config.headers.Authorization = `Bearer ${token}`;
  }
  return config;
});

api.interceptors.response.use(
  (response) => response,
  async (error) => {
    if (error.response?.status === 401) {
      // Token expired - try to refresh
      const newToken = await refreshToken();
      if (newToken) {
        error.config.headers.Authorization = `Bearer ${newToken}`;
        return api.request(error.config);
      }
    }
    return Promise.reject(error);
  }
);

Token refresh pattern

let refreshPromise: Promise<string> | null = null;

async function refreshToken(): Promise<string | null> {
  // Prevent multiple simultaneous refresh requests
  if (refreshPromise) {
    return refreshPromise;
  }

  refreshPromise = fetch('/api/auth/refresh', {
    method: 'POST',
    credentials: 'include', // Send refresh token cookie
  })
    .then((res) => res.json())
    .then((data) => {
      setToken(data.accessToken);
      return data.accessToken;
    })
    .catch(() => {
      // Refresh failed - redirect to login
      window.location.href = '/login';
      return null;
    })
    .finally(() => {
      refreshPromise = null;
    });

  return refreshPromise;
}

Session-based authentication

Sessions store auth state on the server, sending only a session ID to the client.

How sessions work

  1. User logs in with credentials
  2. Server creates session, stores in database/Redis
  3. Server sends session ID in HTTP-only cookie
  4. Browser sends cookie with every request
  5. Server looks up session to identify user

Session cookies in React

// Login - credentials sent, cookie received automatically
async function login(email: string, password: string) {
  const response = await fetch('/api/auth/login', {
    method: 'POST',
    headers: { 'Content-Type': 'application/json' },
    credentials: 'include', // Important: include cookies
    body: JSON.stringify({ email, password }),
  });
  
  if (!response.ok) {
    throw new Error('Login failed');
  }
  
  return response.json();
}

// Protected request - cookie sent automatically
async function fetchProfile() {
  const response = await fetch('/api/profile', {
    credentials: 'include',
  });
  return response.json();
}

// Logout
async function logout() {
  await fetch('/api/auth/logout', {
    method: 'POST',
    credentials: 'include',
  });
}

Session vs JWT comparison

Aspect Sessions JWTs
State Server stores session Token is self-contained
Scalability Needs shared session store Stateless, scales easily
Revocation Immediate (delete session) Hard (wait for expiry)
Size Small cookie (~32 bytes) Larger (~500+ bytes)
Cross-domain Complex (CORS) Easier with Bearer header

OAuth 2.0

OAuth lets users log in with existing accounts (Google, GitHub, etc.).

OAuth 2.0 flows

Flow Use case Security
Authorization Code + PKCE SPAs, mobile apps High
Authorization Code Server-side apps High
Implicit Deprecated, don't use Low
Client Credentials Machine-to-machine High

Authorization Code with PKCE

PKCE (Proof Key for Code Exchange) protects against authorization code interception.

// 1. Generate code verifier and challenge
function generateCodeVerifier(): string {
  const array = new Uint8Array(32);
  crypto.getRandomValues(array);
  return base64UrlEncode(array);
}

async function generateCodeChallenge(verifier: string): Promise<string> {
  const encoder = new TextEncoder();
  const data = encoder.encode(verifier);
  const hash = await crypto.subtle.digest('SHA-256', data);
  return base64UrlEncode(new Uint8Array(hash));
}

function base64UrlEncode(buffer: Uint8Array): string {
  return btoa(String.fromCharCode(...buffer))
    .replace(/\+/g, '-')
    .replace(/\//g, '_')
    .replace(/=+$/, '');
}

// 2. Start OAuth flow
async function startOAuthFlow() {
  const codeVerifier = generateCodeVerifier();
  const codeChallenge = await generateCodeChallenge(codeVerifier);
  
  // Store verifier for later
  sessionStorage.setItem('code_verifier', codeVerifier);
  
  const params = new URLSearchParams({
    client_id: 'your-client-id',
    redirect_uri: 'http://localhost:3000/callback',
    response_type: 'code',
    scope: 'openid profile email',
    code_challenge: codeChallenge,
    code_challenge_method: 'S256',
    state: crypto.randomUUID(), // CSRF protection
  });
  
  window.location.href = `https://provider.com/oauth/authorize?${params}`;
}

// 3. Handle callback
async function handleOAuthCallback() {
  const params = new URLSearchParams(window.location.search);
  const code = params.get('code');
  const codeVerifier = sessionStorage.getItem('code_verifier');
  
  // Exchange code for tokens
  const response = await fetch('https://provider.com/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: 'your-client-id',
      code: code!,
      redirect_uri: 'http://localhost:3000/callback',
      code_verifier: codeVerifier!,
    }),
  });
  
  const tokens = await response.json();
  // tokens.access_token, tokens.id_token, tokens.refresh_token
}

Using auth libraries

Most apps should use a library rather than implementing OAuth manually.

NextAuth.js (Next.js):

// app/api/auth/[...nextauth]/route.ts
import NextAuth from 'next-auth';
import Google from 'next-auth/providers/google';

export const { handlers, signIn, signOut, auth } = NextAuth({
  providers: [
    Google({
      clientId: process.env.GOOGLE_CLIENT_ID!,
      clientSecret: process.env.GOOGLE_CLIENT_SECRET!,
    }),
  ],
});

// Usage in component
import { signIn, signOut, auth } from '@/auth';

export default async function Page() {
  const session = await auth();
  
  if (!session) {
    return <button onClick={() => signIn('google')}>Sign in</button>;
  }
  
  return (
    <div>
      <p>Welcome, {session.user?.name}</p>
      <button onClick={() => signOut()}>Sign out</button>
    </div>
  );
}

Token storage security

Where to store tokens

Location XSS vulnerable CSRF vulnerable Recommendation
localStorage Yes No Never for auth
sessionStorage Yes No Avoid
HTTP-only cookie No Yes (mitigable) Preferred
Memory (variable) No No Good for SPAs
// Server sets cookie
res.cookie('token', jwt, {
  httpOnly: true,    // Not accessible via JavaScript
  secure: true,      // HTTPS only
  sameSite: 'strict', // CSRF protection
  maxAge: 7 * 24 * 60 * 60 * 1000, // 7 days
});

// Frontend just includes credentials
fetch('/api/protected', {
  credentials: 'include',
});

Memory storage pattern

For SPAs with cross-origin APIs:

// Auth context
const AuthContext = createContext<{
  isAuthenticated: boolean;
  login: (credentials: Credentials) => Promise<void>;
  logout: () => void;
} | null>(null);

export function AuthProvider({ children }) {
  const [token, setToken] = useState<string | null>(null);
  
  const login = async (credentials: Credentials) => {
    const response = await fetch('/api/auth/login', {
      method: 'POST',
      body: JSON.stringify(credentials),
    });
    const { accessToken } = await response.json();
    setToken(accessToken);
  };
  
  const logout = () => {
    setToken(null);
  };
  
  return (
    <AuthContext.Provider value={{
      isAuthenticated: !!token,
      login,
      logout,
    }}>
      {children}
    </AuthContext.Provider>
  );
}

Protected routes in React

// ProtectedRoute component
function ProtectedRoute({ children }) {
  const { isAuthenticated, isLoading } = useAuth();
  const router = useRouter();
  
  useEffect(() => {
    if (!isLoading && !isAuthenticated) {
      router.push('/login');
    }
  }, [isAuthenticated, isLoading, router]);
  
  if (isLoading) {
    return <LoadingSpinner />;
  }
  
  if (!isAuthenticated) {
    return null;
  }
  
  return children;
}

// Usage
function App() {
  return (
    <Routes>
      <Route path="/login" element={<LoginPage />} />
      <Route
        path="/dashboard"
        element={
          <ProtectedRoute>
            <DashboardPage />
          </ProtectedRoute>
        }
      />
    </Routes>
  );
}

Common security mistakes

Mistake Risk Fix
JWT in localStorage XSS can steal tokens Use HTTP-only cookies
No CSRF protection Forged requests SameSite cookies, CSRF tokens
Long-lived access tokens Extended compromise window Short access + refresh tokens
Secrets in JWT payload Information disclosure Only put public claims
No token refresh Poor UX on expiry Implement silent refresh
Trusting JWT without verification Token tampering Always verify signature

Debugging auth issues

  1. Inspect tokens: Use JWT Decoder to check claims and expiry
  2. Check cookies: DevTools → Application → Cookies
  3. Verify headers: Network tab → Request headers → Authorization
  4. Test encoding: Use Base64 Encoder for debugging

Use these codefunc tools alongside this guide:

Practical takeaway

Use established auth libraries (NextAuth, Auth0, Clerk) rather than building from scratch. Store tokens in HTTP-only cookies when possible. For SPAs that can't use cookies, store in memory and implement token refresh. Always use HTTPS. Understand that JWTs are signed (tamper-proof) but not encrypted (readable). Keep access tokens short-lived (15 minutes) with longer-lived refresh tokens.

Sources

Try it on codefunc

Related articles

3

Search tools

Find a developer tool