I spend a lot of time reading about JWTs and refresh tokens. At some point I wanted something I could run, break, and fix – a NestJS API that went past “hello world” and forced me to think about email links, cookies, databases, and deployment, not just decorators.
That became a small auth + tasks backend: register, email verification, login, refresh, logout, forgot/reset password, role-based routes, throttling on login, Drizzle + PostgreSQL, Resend for mail, and Swagger so I could share the contract without narrating every endpoint.
Why this shape
Tutorial APIs often stop at POST /login returning a token. Real apps need the boring parts too: invalidating sessions, verifying email, resetting passwords, and making sure production URLs in emails point at the right host. I deliberately included those flows so I could not hand-wave "We'd add that later.”
How 'refresh' works here (the part that bit me)
Access tokens are short-lived; the refresh token is stored in an httpOnly cookie so client-side JS cannot read it. Login sets that cookie; the refresh endpoint reads it and issues a new access token.
The controller reads the cookie by name:
auth.controller.ts
const cookies = req.cookies as Record<string, string>;
const refreshToken = cookies?.refreshToken;
console.log(req.cookies);
return this.authService.refresh(refreshToken, res);
And the service sets it like this:
auth.service.ts
private setRefreshTokenCookie(res: Response, refreshToken: string) {
res.cookie('refreshToken', refreshToken, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 7 * 24 * 60 * 60100, // 7 days
});
}
So far, so familiar.
The bug: logout that did not fully log out
I hit a confusing situation: after calling logout, the API said 'success', and I cleared the refresh token hash in the database — but in the browser, the refresh cookie was still there. Anyone watching the network tab could see the old pattern: call refresh again, and the story got messy depending on timing and validation.
The issue was embarrassingly small. I had set the cookie as 'refreshToken', but on logout I cleared a different name ('refresh_token'). Browsers' key Set-Cookie and removal by exact name (and usually path / flags). Clearing the wrong name is a no-op from the user’s perspective: the real cookie never leaves.
The fix was to clear the same cookie with matching attributes, especially path, so the browser actually deletes what we set:
auth.service.ts
async logout(userId: string, res: Response) {
await this.usersService.update(userId, { refreshTokenHash: null });
res.clearCookie('refreshToken', {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
path: '/',
});
return { message: 'Logged out successfully' };
}
If you ever doubt yourself on this class of bug, open DevTools → Application → Cookies and compare names before and after logout. The cookie store does not lie.
What I learned
Auth is a bundle of agreements. The JWT payload, the cookie name, the DB hash, the APP_URL in password-reset and verification emails, and the environment variables on the host all have to line up. One string drifting (APP_URL still pointing at localhost after deployment) creates bugs that feel “magical” until you trace them end to end.
Serverless deploys punish missing config early. If DATABASE_URL is absent, the app can fail at boot when the DB module loads — generic 500s on Vercel are often solved by reading function logs and fixing the env, then redeploying. I documented that flow in the project README so future me does not repeat the same scavenger hunt.
Typos beat type systems. TypeScript helped everywhere it could, but cookie names are still strings. A disciplined habit is to define cookie names in one constant shared by set, read, and clear so rename drift cannot happen again.
What I am doing next
I want more confidence from automated tests (especially e2e paths for register → verify → login → refresh → logout). After that, either a minimal frontend consuming this API or stricter refresh rotation – depending on whether I want to practice client integration or token hardening first.
Here is the github repo: https://github.com/md-ahr/nestjs-auth
If you are building something similar, you do not need a novel architecture on day one. You need clear flows, honest error handling, and the humility to open cookie storage when the server says “logged out” but the browser disagrees.
Top comments (0)