Implementing Key Rotation Strategy with KIDs for JWT-based Security Systems
Learn how to implement a robust key rotation strategy for JWT-based authentication systems for better security, scalability and data integrity.
1. Introduction
Well, you coming to read this article means that you know what JWT is, so no introduction! And we both know that JWTs can be signed using symmetric (e.g., HS256) or asymmetric algorithms (e.g., RS256). Asymmetric algorithms are preferred in production systems due to the use of a private-public key pair—I wrote an in-depth technical explainer on it for 🫵 you 👉 here.
As with any cryptographic system, managing and safeguarding keys is crucial. Compromised or stale keys can lead to severe security vulnerabilities. Key rotation is a proactive measure to periodically replace keys, minimizing the risk of unauthorized access and ensuring robust system security.
In this article, we will explore the implementation of a key rotation strategy for JWT-based systems using Key IDs (KIDs).
2. Understanding Key Rotation in JWT-based Systems
2.1 What is Key Rotation?
Key rotation is the process of replacing cryptographic keys used for securing sensitive operations, such as signing JWTs, at regular intervals or as needed. It ensures that old keys are retired and new keys are introduced, maintaining the security and integrity of the system.
2.2 Benefits of a key rotation strategy
Enhanced security: By regularly replacing keys, the exposure window of a potentially compromised key is minimized, reducing the risk of attacks.
Reduced risk of key compromise: Frequent rotation ensures that even if a key is leaked or accessed maliciously, its usability is limited to a short timeframe.
Seamless updates without disrupting service: A proper key rotation strategy allows the system to validate tokens signed with both old and new keys, enabling a smooth transition without affecting users or services.
2.3 How Key IDs (KIDs) support key rotation
Key ID (KID) is an identifier embedded in JWT headers to indicate which key was used for signing. It plays a vital role in key rotation by:
Allowing the system to locate the correct key for token validation.
Supporting efficient and scalable management of cryptographic keys in dynamic environments.
3. High-level Architecture of the Implementation
- Generating RSA Key Pairs: RSA key pairs (for RS256) can be generated using tools like OpenSSL or programmatically within your application.
openssl genrsa -out private_key.pem 2048
openssl rsa -in private_key.pem -pubout -out public_key.pem
- Storing the keys
Private Key: This is stored securely in environments like AWS Secrets Manager, HashiCorp Vault, or environment variables. For this guide, we will use environment variables in a Node.js environment.
Public Keys: This is stored in a centralized store such as a database (PostgreSQL in this guide) or Redis.
- Storing Metadata: Alongside the public keys, additional metadata is stored in the database, including:
KID (Key ID): A unique identifier for each key pair. Using UUID or versions like v1, v2, v3.
Expiry Date: Specifies when the key is no longer valid.
Status: Indicates whether the key is active, inactive, or expired.
- Token Signing
Fetch the KID-public key pair with the ACTIVE status from the database.
Retrieve the corresponding private key using the KID from the environment or the configured secret store.
Use a library like
jsonwebtoken
(Node.js),PyJWT
(Python), orGo-JWT
(Go) to sign the token with the private key, embedding the KID into the JWT header for identification during verification.
- Token Verification
Extract the KID from the JWT header during verification.
Fetch the corresponding public key using the KID from the database or store.
Verify the token's signature using the public key to ensure its integrity and authenticity.
- Key Rotation
Generate a new RSA key pair when it's time to rotate keys.
Store the new private key in the environment or secret store and the public key in the database.
Mark the new key as ACTIVE and the old key as INACTIVE.
Tokens signed with the old key can still be verified until they expire, ensuring seamless updates without disrupting the service.
4. Practical Implementation
Before starting the practical implementation, it's important to know the technologies used in this guide: TypeScript, Node.js, PostgreSQL, and Prisma ORM. The key management database table is shown, but the same concepts apply to any tech stack by following the previously outlined architecture.
Here’s the Prisma model for the key management table:
// schema.prisma
model KeyManager {
id Int @id @default(autoincrement())
kid String @unique
keyType String @default("public")
key String
status SecretKeyStatus
expiryDate DateTime
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
}
enum SecretKeyStatus {
ACTIVE
INACTIVE
RETIRED
}
Sample Data:
{
"kid": "v1",
"keyType": "public",
"key": "public-key-1",
"status": "ACTIVE",
"expiryDate": "2025-01-29T19:53:08.371Z"
}
{
"kid": "v2",
"keyType": "public",
"key": "public-key-2",
"status": "INACTIVE",
"expiryDate": "2025-01-29T19:53:08.371Z"
}
4.1 Generating JWTs with Key Rotation
import jwt from "jsonwebtoken";
import { config } from "/common/config";
import prisma from "/services/database";
/**
* @description Generates a JWT token using the provided payload.
* @param {object} payload - The data to be encoded in the JWT.
* @returns {string} - A promise that resolves to the generated JWT token string or null if an error occurs.
*/
export const generateJWTToken = async (payload: {}): Promise<string | null> => {
try {
// Retrieve the active key from the key manager
const activeKey = await prisma.keyManager.findFirst({
where: {
status: "ACTIVE",
}
});
// If no active key is found, log an error and return null
if (!activeKey) {
return null;
}
// Construct the private key name and retrieve it from the authentication configuration
const keyName: string = `JWT_PRIVATE_KEY_${activeKey?.kid}`;
const authConfigs: any = config.authentication;
const privateKey: string = authConfigs[keyName];
// Sign the payload with the private key, including the KID, to generate the JWT token
const token = jwt.sign({ ...payload }, privateKey, {
expiresIn: "30 days",
algorithm: "RS256",
keyid: activeKey.kid,
});
return token;
} catch (error) {
return null;
}
};
// config.ts
import dotenv from "dotenv-safe";
dotenv.config();
export const config = {
authentication: {
JWT_PRIVATE_KEY_v1: process.env.JWT_PRIVATE_KEY_v1?.replace(/\\n/g, "\n"),
JWT_PRIVATE_KEY_v2: process.env.JWT_PRIVATE_KEY_v2?.replace(/\\n/g, "\n"),
JWT_PRIVATE_KEY_v3: process.env.JWT_PRIVATE_KEY_v3?.replace(/\\n/g, "\n"),
JWT_PRIVATE_KEY_v4: process.env.JWT_PRIVATE_KEY_v4?.replace(/\\n/g, "\n"),
}
}
4.2 Verifying JWTs with Rotated Keys
import jwt from "jsonwebtoken";
import prisma from "/services/database";
/**
* @description Verifies a JWT token and returns the decoded token data.
* @param token - The JWT token to be verified.
* @returns A promise that resolves to the decoded token data or null if verification fails.
*/
export const verifyJWTToken = async (token: string): Promise<{} | null> => {
try {
// Decode the JWT token to extract the header
const decodedToken = jwt.decode(token, { complete: true });
const header = decodedToken?.header;
// Extract the key identifier (kid) from the header
const kid = header?.kid;
// Retrieve the public key associated with the kid from the database
const keyManager = await prisma.keyManager.findUnique({
where: {
kid: kid,
}
});
// If no key is found, log an error and return null
if (!keyManager) {
return null;
}
// Prepare the public key for verification
const JWT_PUBLIC_KEY: string = keyManager?.key!.replace(/\\n/g, "\n");
// Verify the token using the public key and return the token data
const tokenData = jwt.verify(token, JWT_PUBLIC_KEY, {
algorithms: ["RS256"],
});
return tokenData;
} catch (error) {
return null;
}
};
5. Recommendation and Conclusion
5.1 Recommendation
The two functions—generateJWTToken
and verifyJWTToken
—can be directly used in a JWT-based authentication system to handle user login and route protection. They can also be extended or modified for other use cases, such as service-to-service communication.
5.2 Conclusion
That’s it for now guys. With this strategy, you not only enhance security of your application, but also build a foundation for handling future challenges seamlessly. Start applying these practices today to safeguard your system and user trust. Feel free to drop a comment if you have any question. Happy Coding! ❤️💡