When building an application, authentication is unavoidable. You can outsource it to services like Clerk or Better Auth and be productive fast, but that convenience often comes with trade-offs: third-party lock-in, limited control over flows, and opaque behavior when things go wrong.
In production systems, authentication is not just about logging users in. It shapes your security model, influences your database design, and quietly dictates how scalable and debuggable your system will be.
In this article, I’ll walk through how I designed and implemented authentication for a real production project, the decisions I had to make, and the compromises that came with them. Since all systems have different auth requirements, it might vary from your version.
Requirements for my project
For this project, authentication needs to be intentionally simple:
email + password–based registration and login, with Google OAuth as an alternative for both signup and sign-in.
If a user registers using email and password, the email must be verified via a verification link. Once the email is verified, the user is automatically logged in. The frontend receives the required tokens and user context without forcing an additional login step.
Strategy
For this project, I’m using a JWT-based authentication approach with short-lived access tokens and refresh tokens, with refresh token rotation enabled.
private async generateTokens(
userId: string,
email: string,
role: number,
deviceId: string,
) {
const accessTokenSecret =
this.configService.get<string>('JWT_ACCESS_SECRET');
const refreshTokenSecret = this.configService.get<string>(
'JWT_REFRESH_TOKEN_SECRET',
);
const accessTokenExpiryRaw = this.configService.get<string>(
'JWT_ACCESS_TOKEN_EXPIRE_TIME',
'1h',
);
const refreshTokenExpiryRaw = this.configService.get<string>(
'JWT_REFRESH_TOKEN_EXPIRE_TIME',
'7d',
);
const accessTokenTtlMs = ms(accessTokenExpiryRaw);
const refreshTokenTtlMs = ms(refreshTokenExpiryRaw);
const [accessToken, refreshToken] = await Promise.all([
this.jwtService.signAsync(
{ sub: userId, email, role },
{
secret: accessTokenSecret,
expiresIn: Math.ceil(accessTokenTtlMs / 1000),
},
),
this.jwtService.signAsync(
{ sub: userId, email, role },
{
secret: refreshTokenSecret,
expiresIn: Math.ceil(refreshTokenTtlMs / 1000),
},
),
]);
// Extract the signature part of the JWT
const accessTokenSignature = accessToken.split('.')[2];
// Keyv expects TTL in ms - cache key includes deviceId for device-specific validation
await this.cacheManager.set(
`${CacheKey.ACCESS_KEY}:${userId}:${deviceId}`,
{ tokenSignature: accessTokenSignature, deviceId },
accessTokenTtlMs,
);
return { accessToken, refreshToken };
}
To minimize authentication lookup latency, access tokens are cached in Redis, mapped to the corresponding user_id and device_id. This allows the system to validate active sessions without hitting the primary database on every authenticated request.
Device Identification
Each client device is assigned a unique deviceId, generated on the backend.
My first approach was to derive this identifier from the IP address and user agent. From a security perspective, this worked well. Tokens were tightly bound to their original context, and replaying them from elsewhere was difficult.
In practice, though, IP addresses change far more often than you’d expect, especially for mobile users. The result was unnecessary session invalidation and a poor user experience.
After weighing those trade-offs, I switched to deriving the deviceId from the user ID and user agent instead.
The goal of the deviceId is simple:
Avoid duplicate session entries in Redis when a user logs in multiple times from the same device
Make session tracking and revocation possible on a per-device basis
Add an extra check during token validation beyond signature and expiry
By anchoring sessions to a deterministic deviceIdRedis maintains a single active access-token entry per device rather than accumulating redundant session data.
In practice, access and refresh tokens are validated not just by their cryptographic signature and expiration, but also by the reason the deviceId they were issued for. This means that even if a token is leaked, it can’t be trivially replayed from a completely different device context unless the attacker also replicates the same user agent.
private generateDeviceFingerprint(userId: string, userAgent: string): string {
const raw = `${userId}|${userAgent}`;
return crypto
.createHash('sha256')
.update(raw)
.digest('hex')
.substring(0, 32);
}
User agents aren’t secret, and this is not meant to be a perfect defense. What it does provide is a meaningful reduction in the blast radius of token leakage and protection against a large class of naive replay attacks.
This approach intentionally trades stronger environmental binding for stability and usability. For this system, that trade-off is acceptable. The deviceId isn’t a cryptographic fingerprint; it’s a pragmatic mechanism to keep sessions predictable, revocable, and reasonably safe in production.
Schema design
For authentication, the core requirements can be met with just two tables: one to represent the user, and another to track login sessions via refresh tokens.
Table users {
id uuid PK // uuidv7
name string
email string UNIQUE NOT NULL
password string null // hashed password
role int NOT NULL // 0 = user, 1 = admin, 2 = super_admin
created_at timestamp
updated_at timestamp
}
Table user_login_tokens {
id uuid PK
user_id uuid FK -> users.id
device_id string
refresh_token_signature string NOT NULL
user_agent string
ip_address string
expires_at timestamp NOT NULL
revoked_at timestamp
created_at timestamp
}
Rate Limiting & Abuse Protection
Authentication endpoints are a natural target for abuse, so basic rate limiting is enforced at the infrastructure level using Redis.
Rather than applying a single global limit, rate limiting is scoped by action and tracked independently by IP address and email. This allows the system to respond differently to suspicious behavior without punishing legitimate users.
The following flows are protected:
- Login attempts.
- Registration attempts
- Forgot password requests
- Each flow has its own threshold, time window, and temporary block duration.
How It Works
For every request, the system checks whether the IP address or email is currently blocked for the given action. If either is blocked, the request is rejected immediately.
On failed attempts, counters are incremented in Redis with a short TTL. Once the configured threshold is exceeded, a temporary block is applied, and the counter is cleared. Successful authentication clears any existing attempt counters.
This keeps rate limiting fast, stateless, and independent of the primary database.
Here’s an example:
this.loginConfig = {
maxAttempts: this.configService.get<number>('AUTH_LOGIN_MAX_ATTEMPTS', 5),
windowMs: this.configService.get<number>('AUTH_LOGIN_WINDOW_MS', 60000),
blockDurationMs: this.configService.get<number>('AUTH_LOGIN_BLOCK_MS',
60000,
),
};
// Registration: 3 attempts per 5 minutes, blocked for 5 minutes
this.registerConfig = {
maxAttempts: this.configService.get<number>(
'AUTH_REGISTER_MAX_ATTEMPTS',
3,
),
windowMs: this.configService.get<number>(
'AUTH_REGISTER_WINDOW_MS',
300000,
),
blockDurationMs: this.configService.get<number>(
'AUTH_REGISTER_BLOCK_MS',
300000,
),
};
// Forgot Password: 3 attempts per 5 minutes, blocked for 5 minutes
this.forgotPasswordConfig = {
maxAttempts: this.configService.get<number>(
'AUTH_FORGOT_MAX_ATTEMPTS',
3,
),
windowMs: this.configService.get<number>('AUTH_FORGOT_WINDOW_MS', 300000),
blockDurationMs: this.configService.get<number>(
'AUTH_FORGOT_BLOCK_MS',
300000,
),
}
Token Lifecycle & Rotation
Access tokens in this system are intentionally short-lived. Any long-running session relies on refresh tokens, which makes the refresh flow a critical security boundary.
To handle this safely, refresh tokens are rotated on every use.
Refresh Flow
When a client requests a new access token using a refresh token, the system performs the following steps inside a database transaction:
Revoke the existing refresh token
The refresh token used for the request is immediately marked as revoked. This ensures that a refresh token can only be used once.
Re-evaluate the device context
The request’s device information is re-derived on the backend, ensuring that the new tokens are bound to the correct deviceId.
Issue a new token pair
A fresh access token and refresh token are generated and returned to the client.
Persist the new refresh token
The new refresh token is stored in the database, replacing any existing token for the same user-device combination.
This entire flow is executed atomically. Either all steps succeed, or none do.
Why Rotation Matters
Refresh token rotation prevents an entire class of replay attacks.
If a refresh token is leaked and reused:
The first request succeeds and rotates the token
Any subsequent reuse of the old token fails because it has already been revoked
This ensures that a compromised refresh token has a very limited window of usefulness.
Failure Behavior
If a refresh token is revoked, expired, or does not match the stored signature for the given device, the refresh request is rejected. At that point, the client must re-authenticate.
This makes refresh token misuse noisy, detectable, and self-limiting.
Design Trade-off
This approach slightly increases complexity compared to static refresh tokens, but it dramatically improves security and debuggability.
Token rotation turns refresh tokens from long-lived secrets into single-use credentials, which aligns much better with production threat models.
logout and token revocations
Logout is handled server-side.
When a user logs out:
The refresh token for the current session is revoked in the database
The cached access token for the corresponding userId and deviceId is removed from Redis
After this, the session cannot be refreshed, and any in-flight access tokens are rejected.
Logout is scoped to the current device. Other active sessions remain unaffected unless explicitly revoked.
async logout(userId: string, refreshToken: string, request: Request) {
return await this.sequelize.transaction(async (t) => {
const dbOptions = { transaction: t };
// Revoke refresh token in database
await this.tokenRepository.revokeRefreshToken(
userId,
refreshToken,
dbOptions,
);
// Clear access token from cache
// Extract device info and clear specific device cache
const { deviceId } = this.extractDeviceInfo(request, userId);
deleteFromCache(userId, deviceId)
return null;
});
}
Email Verification Token Handling
On events like registration, or forgot password, or a verification token is created, which is sent through email to the user. where the web passes that token along with the other information, i.e., the new password field, to change the password.
These tokens live shortly in Redis; on successful request processing, these tokens are deleted, and the database is updated properly.
Password reset tokens are intentionally not bound to a device. These flows are designed to work across devices and browsers, and binding them to a device context would introduce unnecessary friction without meaningful security benefits.
// a generic implementation without rate limiting, since it's getting long
async resetPassword(email: string, token: string, newPassword: string) {
let payload: any;
try {
payload = await this.jwtService.verifyAsync(token, {
secret: this.configService.get<string>('JWT_VERIFICATION_SECRET'),
});
} catch {
throwBadRequest('auth.INVALID_OR_EXPIRED_TOKEN');
}
if (payload.email !== email || payload.type !== 'password_reset') {
throwBadRequest('auth.INVALID_CREDENTIALS');
}
const tokenSignature = token.split('.')[2];
const cacheKey = `reset_password_token:${tokenSignature}`;
const userId = await this.cacheManager.get<string>(cacheKey);
if (!userId) {
throwBadRequest('auth.INVALID_OR_EXPIRED_TOKEN');
}
const hashedPassword = this.hashPassword(newPassword);
await this.usersService.updateById(userId, { password: hashedPassword });
await this.cacheManager.del(cacheKey);
return null;
}
Closing thoughts
This authentication system is built around clear trade-offs rather than generic patterns. Short-lived access tokens, refresh token rotation, per-device sessions, explicit revocation, and targeted rate limiting together provide predictable behavior and reasonable security without unnecessary complexity.
Follow me on X (formerly known as Twitter): https://x.com/Itstheanurag