End-to-end Guide to building a Reliable Authentication System for a Startup MVP - Part 4
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:
Update Our configs: Edit the
.env
andconfig/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
SecretService Creation: We will create a new
ServiceModule
andSecretService
inside ourlibs
folder. This service will be responsible for generating new secrets and providing the current secret to our application.Secret Storage: Store updated secrets in environment variables, update our NestJS
config
file (and update remote configs if you are using one).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 thekeyId
argument. ThegetCurrent
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 insecretKeys
;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.
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.