Access + Refresh Tokens and MFA, Built From Scratch
Rolling your own auth is usually a mistake — but when you must, the details decide whether it is secure. Here is the access/refresh token + MFA design I built in NestJS, with the pitfalls that bite.
Access + Refresh Tokens and MFA, Built From Scratch
On this page
For the Amharc Tech ecosystem we needed auth that worked the same across web portals and mobile apps, with MFA and push notifications — so I built a custom access/refresh token scheme in NestJS rather than bolt on a provider. Rolling your own auth is a decision to make carefully; here is the design that held up, and the parts that are easy to get wrong.
Two tokens, two jobs
A single long-lived token is a liability: you cannot revoke it, and if it leaks it is valid for its whole lifetime. Splitting the concern fixes both:
- Access token — short-lived (15 minutes), a stateless JWT carrying the user id and roles. Verified on every request with zero database hits.
- Refresh token — long-lived (days), opaque, and stored server-side so it can be revoked. Its only job is to mint new access tokens.
Rotation is the part people skip
Every time a refresh token is used, it is invalidated and replaced. Store only a hash of the current one:
async function refresh(userId: string, presented: string) {
const stored = await tokens.find(userId);
if (!stored || !(await bcrypt.compare(presented, stored.hash))) {
await tokens.revokeAll(userId); // reuse detected → kill the family
throw new UnauthorizedException();
}
const next = randomToken();
await tokens.replace(userId, await bcrypt.hash(next, 10));
return { accessToken: signAccess(userId), refreshToken: next };
}The payoff: if an old refresh token is ever replayed, the hash will not match the current one, and that is your signal a token was stolen. The safe response is to revoke the whole token family and force a fresh login.
MFA as a second gate
With MFA enabled, a correct password does not return tokens — it returns a short-lived challenge. Only after a TOTP code (or an approved push) is verified does the server issue the real token pair. The first factor proves what you know; the second proves what you have. Neither alone gets you in.
Push that closes the loop (FCM)
Firebase Cloud Messaging delivered both the security signals and product notifications: a “new login from a new device” prompt the user can approve or deny, and an instant kill-switch — denying revokes the session server-side. Device tokens are registered at login and cleaned up on logout so messages never chase a stale install.
What I would tell anyone building this
- Keep access tokens short and stateless; keep refresh tokens revocable. That split is the whole point.
- Always rotate refresh tokens and detect reuse — without it you have long-lived bearer tokens with extra steps.
- Store hashes, never raw tokens. A leaked tokens table should be useless.
- If a real IdP fits, use it. Custom auth is justified when you genuinely need to own the flow across many clients — but every detail is now yours to get right.
Comments
Loading comments…