End-to-end Guide to building a Reliable Authentication System for a Startup MVP - Part 4

Taiwo OgunolaTaiwo Ogunola
5 min read

Introduction

In the world of authentication, security isn't just a one-time setup; it's an ongoing process. As we continue our journey of building a reliable authentication system for a startup MVP, we've reached an important step: secrets management and rotation. This article will walk you through setting up a SecretService in NestJS to manage and rotate your JWT secrets efficiently, making sure your system stays secure over time.

Why Secrets Management and Rotation Matter

Secrets, like JWT signing keys, are the backbone of your authentication system. They protect your users' data and maintain the integrity of your authentication flow. However, keeping the same secret forever is like leaving your front door unlocked — it increases the risk of exposure and compromise. By rotating secrets regularly, you reduce the attack surface and ensure that even if one key is compromised, the damage is minimized.

Setting Up the SecretService

We’ll start by building a SecretService in NestJS, which will handle the generation, storage, and rotation of your secrets. Here’s a step-by-step breakdown:

  1. Update Our configs: Edit the .env and config/index.ts files to support having multiple values for our secrets. In this guide, we will only focus on JWT-related secrets, but the same approach can be applied to other secrets.

         // ...other configs
         access_token: {
           secrets: {
             current: {
               envName: 'ACCESS_TOKEN_SECRET_1', // We will need this when trying to save to our .env file
               secret: process.env.ACCESS_TOKEN_SECRET_1,
             },
             previous: {
               envName: 'ACCESS_TOKEN_SECRET_0',
               secret: process.env.ACCESS_TOKEN_SECRET_0,
             },
           },
           expires_in: process.env.ACCESS_TOKEN_EXPIRES_IN || '10min',
         },
         // ...other configs
    
  2. SecretService Creation: We will create a new ServiceModule and SecretService inside our libs folder. This service will be responsible for generating new secrets and providing the current secret to our application.

  3. Secret Storage: Store updated secrets in environment variables, update our NestJS config file (and update remote configs if you are using one).

  4. Graceful Rotation: Implement a strategy where the old secret remains valid for a short period after the new secret is introduced. This allows existing tokens to remain valid while new tokens are signed with the new secret.

SecretService implementation:

// You could also store the keyId inside the JWT header.kid instead,
// And extract it from the token header, but I chose to statically pass the keyId
// around because I want to skip decoding the token first
export enum SecretKeyId {
  AccessToken = 'access_token',
  RefreshToken = 'refresh_token',
  MagicLink = 'magic_link',
  ResetPasswordLink = 'reset_password_link',
}

type SecretConfigType = {
  envName: string;
  secret: string;
};

type SecretType = {
  current: SecretConfigType;
  previous: SecretConfigType;
};

@Injectable()
export class SecretService {
  private readonly logger = new Logger(SecretService.name);
  private readonly secretKeys: SecretKeyId[] = [
    SecretKeyId.AccessToken,
    SecretKeyId.RefreshToken,
    SecretKeyId.MagicLink,
    SecretKeyId.ResetPasswordLink,
  ];

  constructor(private readonly configService: ConfigService) {}

  get(keyId: SecretKeyId) {
    const secret = this.configService.getOrThrow<SecretType>(
      `${keyId}.secrets`,
    );
    return {
      current: secret.current.secret,
      previous: secret.previous.secret,
    };
  }

  getCurrent(keyId: SecretKeyId) {
    return this.get(keyId).current;
  }

  @Cron('0 0 1 * *', { name: 'rotate_secrets' })
  async handleSecretsRotation() {
    try {
      await this.rotateSecrets();
      this.logger.log('Secrets rotated successfully');
    } catch (error) {
      this.logger.error('Failed to rotate secrets', error.stack);
    }
  }

  private generateSecret(length = 32) {
    return crypto.randomBytes(length).toString('hex');
  }

  private async rotateSecrets(): Promise<void> {
    const envConfig = dotenv.parse(await fs.readFile('.env'));

    const rotatedSecrets = this.getRotatedSecrets();

    rotatedSecrets.forEach((secrets) => {
      this.configService.set(secrets.previous.propPath, secrets.previous);
      this.configService.set(secrets.current.propPath, secrets.current);

      envConfig[secrets.previous.envName] = secrets.previous.secret;
      envConfig[secrets.current.envName] = secrets.current.secret;
    });

    const updatedEnvConfig = Object.entries(envConfig)
      .map(([key, value]) => `${key}=${value}`)
      .join('\n');

    await fs.writeFile('.env', updatedEnvConfig);

    // If you are using other secret manager like AWS KMS, Google Secret Manager, etc
    // make sure to update those
  }

  private getRotatedSecrets() {
    return this.secretKeys.map((keyId) => {
      const secret = this.configService.getOrThrow<SecretType>(
        `${keyId}.secrets`,
      );

      return {
        previous: {
          ...secret.previous,
          secret: secret.current.secret,
          propPath: `${keyId}.secrets.previous`,
        },
        current: {
          ...secret.current,
          secret: this.generateSecret(),
          propPath: `${keyId}.secrets.current`,
        },
      };
    });
  }
}
  • We use the get method to retrieve the current and previous secrets given the keyId argument. The getCurrent method retrieves the current (active) secret;

  • generateSecret handles generating new secrets. We currently generate a 32-bit random string, but you can increase this to whatever you see fit;

  • rotateSecrets takes care of rotating all the secrets specified in secretKeys;

  • handleSecretsRotation is a cron job that handles secret rotation at intervals. Right now, it's scheduled to update on the 1st of every month.

If you are utilizing other secret managers such as AWS KMS or Google Secret Manager, you should use their SDKs to update them as well.

Validating Current and Existing Tokens

In our token.service.ts, we created a new function that tries to verify the token with the current (active) secret, and then tries previous secrets if that fails:

 // ... other methods 
 async verifyToken<T>(props: {
    keyId: SecretKeyId;
    token: string;
  }): Promise<T> {
    const secrets = this.secretService.get(props.keyId);
    // Try verifying first with current secret
    try {
      return this.jwt.verifyAsync(props.token, {
        secret: secrets.current,
      }) as T;
    } catch {
      // Token could be expired/malform/signed with other keys
    }

    try {
      return this.jwt.verifyAsync(props.token, {
        secret: secrets.previous,
      }) as T;
    } catch (e) {
      throw new Error('Token is expired or malformed');
    }
  }

// ... other methods

In our jwt-auth.guard.ts, we basically replace all jwt.verifyAsync call with our custom implementation:

    try {
      const payload = await this.tokenService.verifyToken<JwtTokenPayload>({
        token: accessToken,
        keyId: SecretKeyId.AccessToken,
      });

      request['user'] = payload;

      return true;
    } catch {
      // token is expired or signed with a different secret
      // so now check refresh token
    }

Conclusion

Managing and rotating secrets is a critical aspect of maintaining a secure authentication system for your startup MVP. By implementing a robust SecretService in NestJS, you can ensure that your JWT secrets are regularly updated and securely stored. This proactive approach minimizes the risk of exposure and compromise, thereby protecting your users' data and maintaining the integrity of your authentication flow. Remember, security is an ongoing process, and regular secret rotation is a key practice in safeguarding your system over time.

Stay tuned, and as always, you can find the complete code for this article in this GitHub repo.

10
Subscribe to my newsletter

Read articles from Taiwo Ogunola directly inside your inbox. Subscribe to the newsletter, and don't miss out.

Written by

Taiwo Ogunola
Taiwo Ogunola

With over 5 years of experience in delivering technical solutions, I specialize in web page performance, scalability, and developer experience. My passion lies in crafting elegant solutions to complex problems, leveraging a diverse expertise in web technologies.