LIA is a hyperlocal employability platform...
LIA is a hyperlocal employability platform I'm building — fixed jobs, gigs, and a reputation layer for a small, isolated community, matched by proximity instead of routed through a national job board.
This post walks through the real implementation of login and session management (US-002): the backend endpoint, the JWT issuance, and how the frontend persists the session — plus a genuine architecture mismatch this code review turned up, straight from the repository.
The backend: validating credentials and issuing a JWT
The use case is intentionally thin. It finds the user, verifies the password with Argon2id, and returns only public fields:
// application/use-cases/auth/login.use-case.ts
import { verify } from 'argon2';
import { prisma } from '../../../infrastructure/database/prisma.js';
interface LoginRequest {
email: string;
password: string;
}
export class LoginUseCase {
async execute({ email, password }: LoginRequest) {
const user = await prisma.user.findUnique({
where: { email },
});
if (!user) {
throw new Error('Invalid credentials');
}
const passwordMatches = await verify(user.password, password);
if (!passwordMatches) {
throw new Error('Invalid credentials');
}
return {
id: user.id,
name: user.name,
email: user.email,
};
}
}
Note the identical error message for "user not found" and "wrong password" — that's deliberate, not an oversight. Returning a different message for each would let an attacker enumerate which emails are registered.
// presentation/controllers/auth/login.controller.ts
export async function loginController(
request: FastifyRequest<{ Body: LoginBody }>,
reply: FastifyReply,
) {
const { email, password } = request.body;
const useCase = new LoginUseCase();
try {
const user = await useCase.execute({ email, password });
const token = await reply.jwtSign(
{ sub: user.id, email: user.email },
{ expiresIn: process.env.JWT_EXPIRES_IN },
);
return reply.status(200).send({
success: true,
data: { token, user },
});
} catch (error) {
if (error instanceof Error && error.message === 'Invalid credentials') {
return reply.status(401).send({
success: false,
message: 'Invalid credentials',
});
}
request.log.error(error);
return reply.status(500).send({
success: false,
message: 'Internal server error',
});
}
}
The JWT plugin itself is a two-line Fastify plugin, registered globally:
// infrastructure/http/plugins/jwt.ts
import fp from 'fastify-plugin';
import jwt from '@fastify/jwt';
export default fp(async (app) => {
await app.register(jwt, {
secret: process.env.JWT_SECRET!,
});
});
The frontend: where the actual decision gets made
This is the part worth slowing down on. Before this feature was built, an ADR in this same repo had already settled the session strategy: HTTP-only cookies, specifically so the frontend would never handle the raw token. Here's what got implemented instead.
The session is persisted in localStorage:
// services/auth-storage.ts
const STORAGE_KEY = "liahona.auth";
export function saveSession(data: unknown) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(data));
}
export function getSession() {
const storage = localStorage.getItem(STORAGE_KEY);
if (!storage) return null;
return JSON.parse(storage);
}
export function clearSession() {
localStorage.removeItem(STORAGE_KEY);
}
export { STORAGE_KEY };
And every request attaches that token manually, through an Axios interceptor, instead of relying on a cookie the browser would send automatically:
// services/api.ts
export const api = axios.create({
baseURL: "http://localhost:3333",
headers: { "Content-Type": "application/json" },
});
api.interceptors.request.use((config) => {
const session = getSession();
if (session?.token) {
config.headers.Authorization = `Bearer ${session.token}`;
}
return config;
});
api.interceptors.response.use(
(response) => response,
(error) => {
if (error.response?.status === 401) {
clearSession();
window.location.href = "/login";
}
return Promise.reject(error);
},
);
This is a completely standard Bearer-token pattern — and it's also exactly the option the ADR had explicitly weighed against cookies and set aside, mainly because a token sitting in localStorage is readable by any script that runs on the page, which is a real (if often low-probability) XSS exposure. Nobody made a call to override the ADR; the implementation just drifted from it while being built step by step, and nobody re-checked the diff against the decision until this post.
One more artifact from that drift: there are currently two separate auth.service.ts files in the repo — an older one directly under services/, and a newer one under features/auth/services/ with a different response shape (ApiResponse<T> wrapper vs. a flat response):
// features/auth/services/auth.service.ts (current)
interface ApiResponse<T> {
success: boolean;
data: T;
}
export interface LoginResponse {
token: string;
user: { id: string; name: string; email: string };
}
export async function login(data: LoginRequest) {
const response = await api.post<ApiResponse<LoginResponse>>("/login", data);
return response.data.data;
}
Both files still exist. Only the second one is wired into the actual login flow today — the first is a leftover from an earlier pass that never got cleaned up. That's the kind of thing a quick pass of the review checklist from Part 1 catches immediately once you know to look for it.
Wiring it into React: AuthContext and the protected route
The session lives in a plain useState, hydrated once from localStorage on first render:
// contexts/auth-context.tsx
export function AuthProvider({ children }: AuthProviderProps) {
const queryClient = useQueryClient();
const [state, setState] = useState<{ user: AuthUser | null; token: string | null }>(() => {
const session = getSession() as LoginData | null;
return session
? { user: session.user, token: session.token }
: { user: null, token: null };
});
function login(data: LoginData) {
setState({ user: data.user, token: data.token });
saveSession(data);
}
function logout() {
clearSession();
setState({ user: null, token: null });
queryClient.clear();
}
const value = useMemo(
() => ({ user: state.user, token: state.token, isAuthenticated: !!state.token, login, logout }),
[state.user, state.token],
);
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
}
And the protected route is about as small as this pattern gets:
// components/routes/protected-route.tsx
export function ProtectedRoute({ children }: ProtectedRouteProps) {
const { isAuthenticated } = useAuth();
if (!isAuthenticated) {
return <Navigate to="/login" replace />;
}
return children;
}
What's next on this feature
The functional gap is small on paper and real in practice: no GET /me (so a page refresh trusts whatever is sitting in localStorage without asking the backend to confirm it's still valid), no /logout endpoint (logout today is purely client-side state clearing), and a session strategy that's one architecture decision removed from what's documented. None of that is a production blocker for an MVP with no sensitive data yet — but it's now a known, named gap instead of an invisible one, which is the entire value of writing this up.
If you've hit the same thing — a documented decision quietly drifting from the code while pairing with an AI assistant turn by turn — I'd like to hear how you catch it in your own workflow.


Top comments (0)