Skip to content

Authentication and Session Management

RednGift requires secure user authentication without password friction. The platform uses magic link authentication (passwordless login via email) combined with JWT tokens for session management.

  • User experience: Minimize signup friction; users should authenticate quickly
  • Security: Prevent unauthorized access to groups and wishlists
  • Session persistence: Users shouldn’t be logged out on page refresh
  • Multi-device: Support login from different devices simultaneously
  • Backend integration: Stateless authentication compatible with microservices
  • Stateless: No server-side session storage required
  • Scalable: Works across distributed backend services
  • Portable: Token contains user identity and can be verified independently
User Request Login
Enter Email ──────────────────────────────────┐
│ │
▼ ▼
Backend sends magic link ──────────────► Email inbox
│ │
│ ▼
│ User clicks link
│ │
▼ ▼
User arrives at /auth/magic/:token ◄──────────┘
Frontend calls authHandlers.loginWithMagic(token)
Backend validates token & returns JWT
Frontend stores JWT in localStorage
Fetch user info (/users/me)
Update AuthContext.user
Redirect to home page
src/auth/types.ts
export type AuthUserType = null | Record<string, any>;
export type AuthStateType = {
loading: boolean; // true during initialization or login
user: AuthUserType; // null if unauthenticated
};
export type JWTContextType = {
user: AuthUserType;
method: string; // Always 'jwt'
loading: boolean;
authenticated: boolean; // Computed: user !== null
unauthenticated: boolean; // Computed: user === null
login: (magicToken: string) => Promise<void>;
register: (email: string, password: string, firstName: string, lastName: string) => Promise<void>;
logout: () => Promise<void>;
refreshUser: () => Promise<void>; // Re-fetch user data from backend
setAuthToken: (token: string) => Promise<void>;
};

Token location: localStorage.getItem('accessToken')

Why localStorage?

  • Persists across browser tabs and page refreshes
  • Accessible to axios interceptors for automatic header injection
  • Trade-off: Vulnerable to XSS attacks (mitigated by React’s default escaping and CSP)

Why not cookies?

  • Backend is stateless and doesn’t expect cookies
  • Simplifies CORS configuration
  • Client-side single-page app architecture
App mounts
AuthProvider initializes
Read localStorage.accessToken
├─ Token exists ──────────┐
│ │
│ ▼
│ setSession(token)
│ │
│ ▼
│ userHandlers.userInfo()
│ │
│ ├─ Success ──────┐
│ │ │
│ │ ▼
│ │ dispatch(INITIAL, user)
│ │ │
│ │ ▼
│ │ loading = false
│ │ │
│ │ ▼
│ │ Render app (authenticated)
│ │
│ └─ Error ────────┐
│ │
│ ▼
│ Clear token
│ │
│ ▼
│ dispatch(INITIAL, null)
│ │
│ ▼
│ Redirect to /auth/login
└─ No token ──────────┐
dispatch(INITIAL, null)
loading = false
Render app (unauthenticated)

User clicks magic link → Browser opens /auth/magic/:token → Page component extracts token from URL → Calls login(token):

// src/auth/context/jwt/auth-provider.tsx (simplified)
const login = useCallback(async (magikToken: string) => {
const response = await authHandlers.loginWithMagic(magikToken);
if (response.success) {
await setAuthToken(response.token);
localStorage.setItem('isUserReturning', 'true');
}
}, [setAuthToken]);
const setAuthToken = useCallback(async (token: string) => {
setSession(token); // Store in localStorage and set axios default header
const userResponse = await userHandlers.userInfo();
dispatch({
type: Types.LOGIN,
payload: { user: { ...userResponse, token } },
});
}, []);
const logout = useCallback(async () => {
setSession(null); // Clear localStorage and axios header
dispatch({ type: Types.LOGOUT });
}, []);

After logout:

  • AuthContext.user becomes null
  • AuthGuard detects unauthenticated state and redirects to /auth/login
  • WebSocket connection is closed (via WebSocketProvider)
// src/utils/axios.ts (simplified)
import axios from 'axios';
const axiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_HOST_API,
});
// Request interceptor: attach token to all outgoing requests
axiosInstance.interceptors.request.use(
(config) => {
const accessToken = localStorage.getItem('accessToken');
if (accessToken) {
config.headers.Authorization = `Bearer ${accessToken}`;
}
return config;
},
(error) => Promise.reject(error)
);
// Response interceptor: handle 401 Unauthorized
axiosInstance.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
// Token expired or invalid
localStorage.removeItem('accessToken');
window.location.href = '/auth/login';
}
return Promise.reject(error);
}
);
// src/auth/guard/auth-guard.tsx (simplified)
export function AuthGuard({ children }: Props) {
const { authenticated, loading } = useAuthContext();
const router = useRouter();
useEffect(() => {
if (!loading && !authenticated) {
router.replace('/auth/login');
}
}, [authenticated, loading, router]);
if (loading) {
return <LoadingScreen />;
}
if (!authenticated) {
return null; // Will redirect in useEffect
}
return <>{children}</>;
}

Usage: Wrap authenticated layouts in AuthGuard:

// src/app/(home)/layout.tsx
export default function Layout({ children }: Props) {
return (
<AuthGuard>
<WebSocketProvider>
<HomeLayout>{children}</HomeLayout>
</WebSocketProvider>
</AuthGuard>
);
}

GuestGuard redirects authenticated users away from auth pages (e.g., /auth/login). If user is already logged in, redirect to home.

DO:

  • Always check loading state before rendering protected content
  • Use refreshUser() after profile updates to sync AuthContext
  • Capture auth errors with Sentry for debugging token issues

DON’T:

  • Don’t store tokens in Redux (localStorage is the single source of truth)
  • Don’t manually set axios.defaults.headers outside setSession()
  • Don’t call API handlers before AuthProvider initialization completes
  • Invalid token: Backend returns 401 → axios interceptor clears token and redirects to login
  • Network error during login: login() throws error → UI displays toast with retry option
  • Token expiration: No automatic refresh; user must re-authenticate

Assumption: Backend tokens have a long expiration (e.g., 30 days). Refresh token flow not implemented.

  • Initial load: AuthProvider initialization blocks rendering until token validation completes
  • Optimization: Use React.memo() on components that consume AuthContext to prevent unnecessary re-renders
  • Lazy loading: Auth pages are not code-split; consider dynamic imports if bundle size grows
// Test AuthProvider reducer
describe('AuthProvider reducer', () => {
it('should set user on INITIAL action', () => {
const state = reducer(initialState, {
type: Types.INITIAL,
payload: { user: { id: 1, name: 'John' } },
});
expect(state.user).toEqual({ id: 1, name: 'John' });
expect(state.loading).toBe(false);
});
it('should clear user on LOGOUT action', () => {
const state = reducer(
{ user: { id: 1 }, loading: false },
{ type: Types.LOGOUT }
);
expect(state.user).toBeNull();
});
});
// Test login flow
it('should authenticate user with valid magic token', async () => {
const mockToken = 'valid-magic-token';
const mockUser = { id: 1, email: 'test@example.com' };
jest.spyOn(authHandlers, 'loginWithMagic').mockResolvedValue({
success: true,
token: 'jwt-token',
});
jest.spyOn(userHandlers, 'userInfo').mockResolvedValue(mockUser);
const { result } = renderHook(() => useAuthContext(), {
wrapper: AuthProvider,
});
await act(async () => {
await result.current.login(mockToken);
});
expect(result.current.authenticated).toBe(true);
expect(result.current.user).toMatchObject(mockUser);
expect(localStorage.getItem('accessToken')).toBe('jwt-token');
});
  • Test magic link login flow from email to home page
  • Test logout clears session and redirects to login
  • Test 401 response auto-logout behavior
  1. Extend JWTContextType with new methods:
export type JWTContextType = {
// ... existing methods
loginWithGoogle: () => Promise<void>;
loginWithFacebook: () => Promise<void>;
};
  1. Implement OAuth flow in AuthProvider:
const loginWithGoogle = useCallback(async () => {
// Redirect to backend OAuth endpoint
window.location.href = `${HOST_API}/auth/google`;
}, []);
  1. Handle OAuth callback in /auth/callback page:
// Extract token from query params and call setAuthToken
const { token } = useSearchParams();
useEffect(() => {
if (token) setAuthToken(token);
}, [token]);
  1. Store refresh token in httpOnly cookie (backend change required)
  2. Add refresh endpoint handler:
const refreshToken = async () => {
const res = await axios.post('/auth/refresh', {}, { withCredentials: true });
return res.data.accessToken;
};
  1. Update axios interceptor to retry 401 requests after refresh:
axiosInstance.interceptors.response.use(
(response) => response,
async (error) => {
if (error.response?.status === 401 && !error.config._retry) {
error.config._retry = true;
const newToken = await refreshToken();
setSession(newToken);
return axiosInstance(error.config);
}
return Promise.reject(error);
}
);