mTLS Setup How-To
Step-by-step procedures for enabling Mutual TLS (mTLS) on an AWS HTTP API Gateway, configuring the Next.js BFF client, rotating certificates, and testing with Bruno.
Design rationale, security objectives, and certificate hierarchy are covered in the architecture documentation for the Arda Network.
Phase 1: OAM Account (Security Hub)
Section titled “Phase 1: OAM Account (Security Hub)”Perform all steps in Phase 1 in the OAM Account.
1.1 Generate Certificates
Section titled “1.1 Generate Certificates”Run these commands locally or in AWS CloudShell to generate your chain of trust.
# 1. Generate Root CA Key (4096-bit for long-lived CA)openssl genrsa -out MyRootCA.key 4096
# 2. Generate Root CA Certificate (The Truststore)openssl req -x509 -new -nodes -key MyRootCA.key -sha256 -days 3650 -out MyRootCA.pem -subj "/CN=ArdaPrivateRootCA"
# 3. Initialize serial number file (for tracking issued certificates)echo "01" > MyRootCA.srl
# 4. Generate Client Private Key (2048-bit is sufficient for 1-year certs)openssl genrsa -out client.key 2048
# 5. Create Client CSR (use environment-specific naming: {Environment}-{Application}-Client)openssl req -new -key client.key -out client.csr -subj "/CN=Prod-NextJs-Client"
# 6. Sign Client Cert with Root CAopenssl x509 -req -in client.csr -CA MyRootCA.pem -CAkey MyRootCA.key -CAserial MyRootCA.srl -out client.pem -days 365 -sha2561.2 Store Root CA in Secrets Manager
Section titled “1.2 Store Root CA in Secrets Manager”Caution: The Root CA private key is the foundation of your mTLS security. Store it immediately and delete local copies.
- Navigate to AWS Secrets Manager.
- Store a new secret → Other type of secret.
- Add Key/Value pairs:
ROOT_CA_KEY: (Content ofMyRootCA.key)ROOT_CA_CERT: (Content ofMyRootCA.pem)
- Name the secret:
{Environment}/Arda/RootCA(e.g.,Prod/Arda/RootCA). - Add a description:
Root CA for mTLS - DO NOT DELETE. - Copy the Secret ARN.
- Securely delete local copies:
Terminal window shred -u MyRootCA.key # Linux/macOS with coreutils# Or on macOS: rm -P MyRootCA.key
1.3 Store Client Certificate in Secrets Manager
Section titled “1.3 Store Client Certificate in Secrets Manager”- Store a new secret → Other type of secret.
- Add Key/Value pairs:
MTLS_KEY: (Content ofclient.key)MTLS_CERT: (Content ofclient.pem)
- Name the secret:
{Environment}/NextJs/MtlsKeys(e.g.,Prod/NextJs/MtlsKeys). - Copy the Secret ARN (needed in Phase 3).
1.4 Configure Cross-Account Access
Section titled “1.4 Configure Cross-Account Access”Apply this resource policy to both secrets (Root CA and Client Certificates):
{ "Version": "2012-10-17", "Statement": [ { "Sid": "AllowAmplifyBffAccess", "Effect": "Allow", "Principal": { "AWS": "arn:aws:iam::WORKLOAD_ACCOUNT_ID:role/amplify-APPID-BRANCH-XXXXXX" }, "Action": "secretsmanager:GetSecretValue", "Resource": "*", "Condition": { "StringEquals": { "secretsmanager:ResourceTag/Environment": "Prod" } } } ]}Replace WORKLOAD_ACCOUNT_ID with the 12-digit workload account ID. Find the Amplify service role in Amplify Console → App Settings → General → Service Role. Tag both secrets with Environment=Prod after creation.
Phase 2: Workload Account (Infrastructure)
Section titled “Phase 2: Workload Account (Infrastructure)”Perform all steps in Phase 2 in the Workload Account.
2.1 Deploy Public Truststore
Section titled “2.1 Deploy Public Truststore”- Create an S3 bucket:
- Name:
arda-mtls-truststore-{environment}(e.g.,arda-mtls-truststore-prod) - Block all public access: ON
- Versioning: Enabled
- Server-side encryption: AES-256 or AWS KMS
- Name:
- Upload the public
MyRootCA.pemfile from Phase 1.1. - Copy the S3 URI (e.g.,
s3://arda-mtls-truststore-prod/MyRootCA.pem).
2.2 Disable Default Endpoint (Anti-Bypass)
Section titled “2.2 Disable Default Endpoint (Anti-Bypass)”Important: Disable the default endpoint BEFORE enabling mTLS to prevent bypass attacks.
- Go to API Gateway → APIs → Select your API → Settings.
- Default endpoint: Set to Disabled.
- Save.
2.3 Pre-Flight Verification
Section titled “2.3 Pre-Flight Verification”# 1. Verify truststore is uploadedaws s3 ls s3://arda-mtls-truststore-prod/MyRootCA.pem
# 2. Verify certificate chainopenssl verify -CAfile MyRootCA.pem client.pem# Expected: client.pem: OK2.4 Configure API Gateway Domain for mTLS
Section titled “2.4 Configure API Gateway Domain for mTLS”- Navigate to API Gateway → Custom domain names.
- Select your existing Custom Domain → Click Edit.
- Mutual TLS authentication: Toggle ON.
- Truststore URI: Paste the S3 URI from step 2.1.
- Save changes.
Warning: This immediately enforces mTLS. External clients without the certificate will lose access.
Phase 3: Amplify Next.js Implementation
Section titled “Phase 3: Amplify Next.js Implementation”3.1 Grant IAM Permissions
Section titled “3.1 Grant IAM Permissions”Create a managed policy named MtlsSecretsAccess-{Environment} and attach it to the Amplify service role:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "AccessMtlsSecrets", "Effect": "Allow", "Action": "secretsmanager:GetSecretValue", "Resource": [ "arn:aws:secretsmanager:us-east-1:OAM_ACCOUNT_ID:secret:Prod/NextJs/MtlsKeys-*", "arn:aws:secretsmanager:us-east-1:OAM_ACCOUNT_ID:secret:Prod/Arda/RootCA-*" ] } ]}The * suffix handles the random suffix AWS appends to secret ARNs.
3.2 Implement the Secure Client
Section titled “3.2 Implement the Secure Client”Create lib/secureApi.ts in the Next.js project:
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";import https from "https";import axios, { AxiosError } from "axios";
const OAM_REGION = process.env.OAM_REGION ?? "us-east-1";const SECRET_ARN = process.env.MTLS_SECRET_ARN ?? "";const API_DOMAIN = process.env.API_DOMAIN ?? "api.arda.cards";
// TTL-based cache for certificate rotation supportlet httpsAgent: https.Agent | null = null;let agentCreatedAt: number = 0;const AGENT_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
const TLS_ERROR_CODES = [ "ECONNRESET", "CERT_HAS_EXPIRED", "UNABLE_TO_VERIFY_LEAF_SIGNATURE", "ERR_TLS_CERT_ALTNAME_INVALID"];
function isTlsError(error: unknown): boolean { if (error instanceof AxiosError) { return TLS_ERROR_CODES.includes(error.code ?? ""); } return false;}
async function getMtlsAgent(forceRefresh = false): Promise<https.Agent> { const now = Date.now(); const isExpired = (now - agentCreatedAt) > AGENT_TTL_MS;
if (httpsAgent && !isExpired && !forceRefresh) { return httpsAgent; }
if (httpsAgent) { httpsAgent.destroy(); httpsAgent = null; }
const client = new SecretsManagerClient({ region: OAM_REGION }); const command = new GetSecretValueCommand({ SecretId: SECRET_ARN }); const response = await client.send(command);
if (!response.SecretString) { throw new Error("MTLS secret is empty or not found"); } const secrets = JSON.parse(response.SecretString);
if (!secrets.MTLS_CERT || !secrets.MTLS_KEY) { throw new Error("MTLS secret missing required MTLS_CERT or MTLS_KEY"); }
httpsAgent = new https.Agent({ cert: secrets.MTLS_CERT, key: secrets.MTLS_KEY, servername: API_DOMAIN, rejectUnauthorized: true, keepAlive: true, maxSockets: 10 });
agentCreatedAt = now; return httpsAgent;}
export async function secureApiCall( path: string, method: "GET" | "POST" | "PUT" | "DELETE", userToken: string, data?: unknown, retryOnTlsError = true) { const agent = await getMtlsAgent();
try { return await axios({ method, url: `https://${API_DOMAIN}${path}`, httpsAgent: agent, data, headers: { Authorization: `Bearer ${userToken}`, "Content-Type": "application/json" }, timeout: 30000 }); } catch (error) { if (retryOnTlsError && isTlsError(error)) { const freshAgent = await getMtlsAgent(true); return axios({ method, url: `https://${API_DOMAIN}${path}`, httpsAgent: freshAgent, data, headers: { Authorization: `Bearer ${userToken}`, "Content-Type": "application/json" }, timeout: 30000 }); } throw error; }}3.3 Environment Variables
Section titled “3.3 Environment Variables”OAM_REGION=us-east-1MTLS_SECRET_ARN=arn:aws:secretsmanager:us-east-1:OAM_ACCOUNT_ID:secret:Prod/NextJs/MtlsKeys-xxxxxxAPI_DOMAIN=api.arda.cards3.4 Usage in an API Route
Section titled “3.4 Usage in an API Route”import { secureApiCall } from "@/lib/secureApi";
export default async function handler(req, res) { const userToken = req.headers.authorization?.split(" ")[1]; if (!userToken) return res.status(401).json({ error: "Unauthorized" });
try { const result = await secureApiCall("/protected-data", "GET", userToken); res.status(200).json(result.data); } catch (error) { res.status(500).json({ error: "Backend request failed" }); }}Phase 4: Automatic Certificate Rotation
Section titled “Phase 4: Automatic Certificate Rotation”4.1 Create the Rotation Lambda
Section titled “4.1 Create the Rotation Lambda”Create a Lambda function in the OAM Account (Node.js 18.x runtime).
Required IAM permissions for the Lambda role:
{ "Version": "2012-10-17", "Statement": [ { "Sid": "ReadRootCA", "Effect": "Allow", "Action": "secretsmanager:GetSecretValue", "Resource": "arn:aws:secretsmanager:us-east-1:OAM_ACCOUNT_ID:secret:Prod/Arda/RootCA-*" }, { "Sid": "UpdateClientCert", "Effect": "Allow", "Action": "secretsmanager:PutSecretValue", "Resource": "arn:aws:secretsmanager:us-east-1:OAM_ACCOUNT_ID:secret:Prod/NextJs/MtlsKeys-*" } ]}Lambda function code (index.mjs):
import { SecretsManagerClient, GetSecretValueCommand, PutSecretValueCommand } from "@aws-sdk/client-secrets-manager";import forge from "node-forge";
const secretsManager = new SecretsManagerClient({ region: "us-east-1" });const ROOT_CA_SECRET = process.env.ROOT_CA_SECRET_ARN;const CLIENT_SECRET = process.env.CLIENT_SECRET_ARN;const CERT_VALIDITY_DAYS = 365;const COMMON_NAME = process.env.CERT_COMMON_NAME || "Prod-NextJs-Client";
export const handler = async (event) => { const rootCaResponse = await secretsManager.send( new GetSecretValueCommand({ SecretId: ROOT_CA_SECRET }) ); const rootCa = JSON.parse(rootCaResponse.SecretString);
const caCert = forge.pki.certificateFromPem(rootCa.ROOT_CA_CERT); const caKey = forge.pki.privateKeyFromPem(rootCa.ROOT_CA_KEY);
const clientKeys = forge.pki.rsa.generateKeyPair(2048); const clientCert = forge.pki.createCertificate(); clientCert.publicKey = clientKeys.publicKey; clientCert.serialNumber = Date.now().toString(16);
clientCert.validity.notBefore = new Date(); clientCert.validity.notAfter = new Date(); clientCert.validity.notAfter.setDate( clientCert.validity.notBefore.getDate() + CERT_VALIDITY_DAYS );
clientCert.setSubject([{ name: "commonName", value: COMMON_NAME }]); clientCert.setIssuer(caCert.subject.attributes); clientCert.setExtensions([ { name: "basicConstraints", cA: false }, { name: "keyUsage", digitalSignature: true, keyEncipherment: true }, { name: "extKeyUsage", clientAuth: true } ]);
clientCert.sign(caKey, forge.md.sha256.create());
const clientCertPem = forge.pki.certificateToPem(clientCert); const clientKeyPem = forge.pki.privateKeyToPem(clientKeys.privateKey);
await secretsManager.send(new PutSecretValueCommand({ SecretId: CLIENT_SECRET, SecretString: JSON.stringify({ MTLS_KEY: clientKeyPem, MTLS_CERT: clientCertPem }) }));
return { statusCode: 200, body: JSON.stringify({ message: "Certificate rotated successfully", validUntil: clientCert.validity.notAfter.toISOString(), commonName: COMMON_NAME }) };};Lambda environment variables:
ROOT_CA_SECRET_ARN=arn:aws:secretsmanager:us-east-1:OAM_ACCOUNT_ID:secret:Prod/Arda/RootCACLIENT_SECRET_ARN=arn:aws:secretsmanager:us-east-1:OAM_ACCOUNT_ID:secret:Prod/NextJs/MtlsKeysCERT_COMMON_NAME=Prod-NextJs-ClientRequired npm dependency:
{ "dependencies": { "@aws-sdk/client-secrets-manager": "^3.0.0", "node-forge": "^1.3.1" }}4.2 Configure EventBridge Scheduler
Section titled “4.2 Configure EventBridge Scheduler”- Navigate to Amazon EventBridge → Scheduler → Schedules → Create schedule.
- Configure:
- Name:
mtls-cert-rotation-prod - Schedule expression:
rate(90 days) - Flexible time window: 15 minutes
- Name:
- Target: AWS Lambda Invoke → select your rotation Lambda.
- Retry policy: Maximum retries 3, Maximum event age 1 hour.
- Dead-letter queue: configure an SQS queue for failed rotations.
The Next.js BFF picks up rotated certificates automatically via the TTL-based cache and TLS error retry logic in getMtlsAgent(). No restart or redeployment is required when certificates rotate.
Verification
Section titled “Verification”Test 1: Reject Requests Without Certificate
Section titled “Test 1: Reject Requests Without Certificate”# Should return 403 Forbiddencurl -v https://api.arda.cards/healthTest 2: Reject Requests with Unknown CA
Section titled “Test 2: Reject Requests with Unknown CA”openssl req -x509 -newkey rsa:2048 -keyout rogue.key -out rogue.pem \ -days 1 -nodes -subj "/CN=RogueClient"curl --cert rogue.pem --key rogue.key https://api.arda.cards/health# Expected: HTTP/2 403Test 3: Accept Valid mTLS Request
Section titled “Test 3: Accept Valid mTLS Request”curl --cert client.pem --key client.key https://api.arda.cards/health# Expected: HTTP/2 200Test 4: Default Endpoint Inaccessible
Section titled “Test 4: Default Endpoint Inaccessible”curl -v https://abc123.execute-api.us-east-1.amazonaws.com/health# Expected: DNS resolution failure or connection refusedConfiguring Bruno for mTLS Testing
Section titled “Configuring Bruno for mTLS Testing”GUI Configuration
Section titled “GUI Configuration”-
Open your Bruno collection → gear icon → Settings → Client Certificates.
-
Click Add Certificate and fill in:
Field Value Domain api.arda.cardsCertificate Type PEM Certificate File Path to client.pemKey File Path to client.key -
If your system does not trust the Root CA, go to Bruno Preferences → Certificates → enable Use Custom CA Certificate → select
MyRootCA.pem.
Collection Configuration File
Section titled “Collection Configuration File”For team sharing, configure certificates in the collection’s bruno.json:
{ "version": "1", "name": "Arda API", "type": "collection", "clientCertificates": { "enabled": true, "certs": [ { "domain": "api.arda.cards", "type": "pem", "certFilePath": "./certs/client.pem", "keyFilePath": "./certs/client.key", "passphrase": "" } ] }}Store certificate files in a certs/ directory and add that directory to .gitignore.
Bruno with 1Password CLI
Section titled “Bruno with 1Password CLI”Extract certificates to temporary files before launching Bruno:
op read "op://Development/Arda mTLS Client Certificate/client_cert" > /tmp/mtls-client.pemop read "op://Development/Arda mTLS Client Certificate/client_key" > /tmp/mtls-client.keychmod 600 /tmp/mtls-client.keyThen run the Bruno CLI:
BRUNO_CLIENT_CERT_PATH=/tmp/mtls-client.pem \BRUNO_CLIENT_KEY_PATH=/tmp/mtls-client.key \bru run --env productionTroubleshooting
Section titled “Troubleshooting”| Issue | Solution |
|---|---|
UNABLE_TO_VERIFY_LEAF_SIGNATURE | Add Root CA in Bruno Preferences → Certificates |
ECONNRESET | Check certificate and key file paths are correct |
| Certificate not being sent | Verify domain in certificate config matches request URL exactly |
Handshake failed | Check certificate expiry: openssl x509 -in client.pem -noout -dates |
Copyright: © Arda Systems 2025-2026, All rights reserved