Thinking auth in producation systems a dive on the backend

Thinking auth in producation systems a dive on the backend

posted Originally published at medium.com 7 min read

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:

  1. Login attempts.
  2. Registration attempts
  3. Forgot password requests
  4. 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

1 Comment

0 votes
0

More Posts

Merancang Backend Bisnis ISP: API Pelanggan, Paket Internet, Invoice, dan Tiket Support

Masbadar - Mar 13

Systems Thinking: Thriving in the Third Golden Age of Software

Tom Smithverified - Apr 15

How I Built a React Portfolio in 7 Days That Landed ₹1.2L in Freelance Work

Dharanidharan - Feb 9

Beyond the 98.6°F Myth: Defining Personal Baselines in Health Management

Huifer - Feb 2

The Audit Trail of Things: Using Hashgraph as a Digital Caliper for Provenance

Ken W. Algerverified - Apr 28
chevron_left

Related Jobs

View all jobs →

Commenters (This Week)

5 comments
1 comment

Contribute meaningful comments to climb the leaderboard and earn badges!