mTLS Implementation on Arda Network
This document describes the step by step installation of mTLS at Arda
Network Structure¶
Description¶
- Access to the Network for an Arda Environment is done through a single AWS Http API Gateway.
- The API Gateway has one or several custom domains to access it and uses AWS Cognito to authenticate users with OAuth2.0 protocol.
- There is a Cognito instance per environment.
- Access to the API Endpoints is mainly from the Arda Front End Application, implemented as a React Single Page Application in the browser and a
NextJSAWS Amplify application acting asBackEnd For Frontend(BFF) for all traffic to the API Gateway. - All Certificate management and secure access to private keys is centralized in a dedicated AWS Account separate from the account where the environment runs.
Goal¶
Implement Mutual TLS (mTLS) to provide transport-layer client authentication for all traffic to the Arda API Gateway, complementing the existing OAuth 2.0 authorization.
Security Objectives¶
- Defense in Depth: Add a second authentication layer (mTLS) alongside OAuth 2.0
- Zero Trust: Verify client identity at the network layer before processing any request
- Centralized Key Management: All private keys stored in a dedicated OAM account, never in workload accounts
Functional Requirements¶
| ID | Requirement |
|---|---|
| FR-1 | Secure pre-existing API Gateway custom domains with mTLS |
| FR-2 | Generate and store all private keys in OAM Account Secrets Manager |
| FR-3 | Enable Amplify BFF to obtain client certificates from OAM account |
| FR-4 | Disable default API Gateway endpoint to prevent mTLS bypass |
| FR-5 | Implement automatic certificate rotation (90-day cycle) |
Non-Functional Requirements¶
| ID | Requirement | Target |
|---|---|---|
| NFR-1 | Certificate rotation without downtime | 0 seconds service interruption |
| NFR-2 | Secret access audit logging | 100% of access events logged |
| NFR-3 | Recovery from misconfiguration | < 5 minutes to rollback |
| NFR-4 | Cross-account access | Least privilege IAM policies |
Scope¶
| Traffic Path | Authentication |
|---|---|
| Browser → Amplify | HTTPS + OAuth 2.0 |
| Amplify BFF → API Gateway | HTTPS + mTLS + OAuth 2.0 |
| Mobile App → API Gateway | Not in scope (future consideration) |
Threat Model¶
| Threat | Mitigation via mTLS |
|---|---|
| Unauthorized API access | Only clients with valid mTLS certificates can connect |
| Token theft/replay | Even with stolen OAuth token, attacker cannot connect without client cert |
| Man-in-the-middle | Mutual authentication ensures both client and server identity |
| Bypassing custom domain | Disable default endpoint (*.execute-api.amazonaws.com) |
Certificate Hierarchy¶
-
Root CA (10-year validity, stored in OAM Secrets Manager)
- Issues client certificates directly (no intermediate CA for simplicity)
-
Client Certificate (365-day validity, stored in OAM Secrets Manager)
- Common Name:
{Environment}-{Application}-Client(e.g.,Prod-NextJs-Client) - Rotated automatically every 90 days
- Common Name:
-
Truststore (S3 in Workload Account)
- Contains Root CA public certificate only
- Updated only during Root CA rotation (rare)
Environment Strategy¶
| Environment | OAM Account | mTLS Enabled | Certificate Naming |
|---|---|---|---|
| Development | Shared OAM | Optional | Dev-NextJs-Client |
| Staging | Shared OAM | Required | Staging-NextJs-Client |
| Production | Shared OAM | Required | Prod-NextJs-Client |
Note
All environments share a single OAM account but use separate secrets with environment-prefixed names.
Rollback Plan¶
If mTLS enforcement causes an outage:
- Immediate: Toggle mTLS OFF on API Gateway custom domain (reverts to HTTPS-only)
- Investigate: Check S3 truststore URI and certificate validity
- Re-enable: Fix configuration and re-enable mTLS
Warning
Disabling mTLS exposes the API to requests without client certificates. This should be a temporary emergency measure only.
Acceptance Criteria¶
- API Gateway rejects requests without valid client certificate
- API Gateway rejects requests from certificates signed by unknown CAs
- Amplify BFF successfully makes API calls with mTLS + OAuth
- Default endpoint
*.execute-api.amazonaws.comis inaccessible - Certificate rotation completes without service interruption
- Secrets Manager access is logged in CloudTrail
- Cross-account IAM policy follows least privilege
Steps to enable mTLS¶
This guide details how to manually secure an existing AWS HTTP API Gateway using Mutual TLS (mTLS) and OAuth2. It follows a security-best-practice architecture where sensitive keys are generated and stored in a separate OAM (Operations & Management) Account, while the application runs in a Workload Account.
Architecture Overview¶
- Account A (OAM/Security): Generates Certificate Authority (CA), issues Client Certificates, and stores Private Keys in AWS Secrets Manager.
- Account B (Workload): Hosts the AWS API Gateway, the Public Truststore (S3), and the Amplify Next.js Application.
The interactions are summarized in the block diagram
Phase 1: OAM Account (Security Hub)¶
Perform these steps in the OAM Account.
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)
# Format: {Environment}-{Application}-Client
openssl req -new -key client.key -out client.csr -subj "/CN=Prod-NextJs-Client"
# 6. Sign Client Cert with Root CA
openssl x509 -req -in client.csr -CA MyRootCA.pem -CAkey MyRootCA.key -CAserial MyRootCA.srl -out client.pem -days 365 -sha256
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 of MyRootCA.key)ROOT_CA_CERT: (Content of MyRootCA.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:
1.3 Store Client Certificate in Secrets Manager¶
- Store a new secret → Other type of secret.
- Add Key/Value pairs:
MTLS_KEY: (Content of client.key)MTLS_CERT: (Content of client.pem)
- Name the secret:
{Environment}/NextJs/MtlsKeys(e.g.,Prod/NextJs/MtlsKeys). - Copy the Secret ARN (You will need this for Phase 3).
1.4 Configure Cross-Account Access¶
Important
Apply this policy to BOTH secrets (Root CA and Client Certificates).
In each Secret’s details page, edit Resource Permissions and add this policy:
{
"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"
}
}
}
]
}
Configuration notes:
- Replace
WORKLOAD_ACCOUNT_IDwith the 12-digit ID of Account B - Replace
amplify-APPID-BRANCH-XXXXXXwith the actual Amplify service role- Find this in Amplify Console → App Settings → General → Service Role
- The
Conditionensures only secrets tagged withEnvironment=Prodare accessible - Tag both secrets with
Environment=Prodafter creation
Phase 2: Workload Account (Infrastructure)¶
Perform these steps in the Workload Account.
2.1 Deploy Public Truststore¶
Create an S3 bucket with proper security settings:
- Create an S3 Bucket:
- Name:
arda-mtls-truststore-{environment}(e.g.,arda-mtls-truststore-prod) - Block all public access: ON
- Versioning: Enabled (for audit trail and rollback)
- 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).
Note
API Gateway accesses S3 via its service role. Public access is NOT required.
2.2 Disable Default Endpoint (Anti-Bypass)¶
Important
Disable the default endpoint BEFORE enabling mTLS to prevent bypass attacks.
To prevent attackers from bypassing mTLS by using the default AWS URL (*.execute-api...):
- Go to your API Gateway → APIs → Select your API → Settings.
- Default endpoint: Set to Disabled.
- Save.
2.3 Pre-Flight Verification¶
Before enabling mTLS, verify all prerequisites:
# 1. Verify truststore is uploaded
aws s3 ls s3://arda-mtls-truststore-prod/MyRootCA.pem
# 2. Verify client certificate can connect (will fail until mTLS is enabled)
curl -v https://api.arda.cards/health
# Expected: 200 OK (currently no mTLS required)
# 3. Verify certificate chain
openssl verify -CAfile MyRootCA.pem client.pem
# Expected: client.pem: OK
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 will immediately enforce mTLS. External clients without the cert will lose access.
Phase 3: Amplify Next.js Implementation (The Client)¶
Configure the Next.js BFF (Backend for Frontend) to fetch keys and proxy requests.
3.1 Grant IAM Permissions¶
Locate the IAM Role used by your Amplify App (Compute/Service Role).
Create a managed policy named MtlsSecretsAccess-{Environment}:
{
"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-*"
]
}
]
}
Attach this policy to the Amplify service role.
Note
The * suffix in the ARN handles the random suffix AWS adds to secret ARNs.
3.2 Implement the Secure Client¶
Create a utility file lib/secureApi.ts in your Next.js project:
import { SecretsManagerClient, GetSecretValueCommand } from "@aws-sdk/client-secrets-manager";
import https from "https";
import axios, { AxiosError } from "axios";
// Configuration (use environment variables in production)
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 support (NFR-1)
let httpsAgent: https.Agent | null = null;
let agentCreatedAt: number = 0;
const AGENT_TTL_MS = 4 * 60 * 60 * 1000; // 4 hours
// TLS error codes that indicate certificate issues
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;
}
// Clear stale agent
if (httpsAgent) {
httpsAgent.destroy();
httpsAgent = null;
}
try {
// 1. Initialize Client for the OAM Region
const client = new SecretsManagerClient({ region: OAM_REGION });
// 2. Fetch the Secret
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);
// 3. Validate secret structure
if (!secrets.MTLS_CERT || !secrets.MTLS_KEY) {
throw new Error("MTLS secret missing required MTLS_CERT or MTLS_KEY");
}
// 4. Create the Agent
httpsAgent = new https.Agent({
cert: secrets.MTLS_CERT,
key: secrets.MTLS_KEY,
servername: API_DOMAIN, // Critical for SNI
rejectUnauthorized: true,
keepAlive: true,
maxSockets: 10
});
agentCreatedAt = now;
console.log(`[mTLS] Agent initialized/refreshed at ${new Date(now).toISOString()}`);
return httpsAgent;
} catch (error) {
console.error("[mTLS] Failed to initialize agent:", error);
throw error;
}
}
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 // 30 second timeout
});
} catch (error) {
// Detect TLS/certificate errors and retry with fresh certs
if (retryOnTlsError && isTlsError(error)) {
console.warn("[mTLS] TLS error detected, refreshing certificates and retrying...");
const freshAgent = await getMtlsAgent(true); // Force refresh
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¶
Add these to your Amplify environment configuration:
OAM_REGION=us-east-1
MTLS_SECRET_ARN=arn:aws:secretsmanager:us-east-1:OAM_ACCOUNT_ID:secret:Prod/NextJs/MtlsKeys-xxxxxx
API_DOMAIN=api.arda.cards
3.4 Usage in API Route¶
In your Next.js API route (pages/api/… or App Router route.ts):
import { secureApiCall } from "@/lib/secureApi";
export default async function handler(req, res) {
// Extract OAuth token from frontend request
const userToken = req.headers.authorization?.split(" ")[1];
if (!userToken) return res.status(401).json({ error: "Unauthorized" });
try {
// Make the double-secured call (mTLS + OAuth)
const result = await secureApiCall("/protected-data", "GET", userToken);
res.status(200).json(result.data);
} catch (error) {
console.error("[API] Backend request failed:", error);
res.status(500).json({ error: "Backend request failed" });
}
}
Setup Automatic Certificate Rotation¶
Since certificates expire, you need a mechanism to rotate them without downtime. This section details how to automate client certificate rotation using a Lambda function triggered by EventBridge Scheduler every 90 days (per FR-5).
Architecture¶
- EventBridge Scheduler (Account A): Triggers the rotation Lambda on a 90-day schedule.
- Lambda (Account A):
- Fetches the Root CA from Secrets Manager
- Generates a new client key pair
- Signs the new client certificate with the Root CA
- Updates the client secret in Secrets Manager
- Next.js BFF (Account B): Automatically picks up new certificates via TTL-based cache refresh (implemented in Phase 3.2).
Note
Client certificate rotation does NOT require changes to Account B, since the Root CA remains the same and is already trusted.
4.1 Create the Rotation Lambda¶
Create a Lambda function in the OAM Account (Node.js 18.x runtime).
Required IAM Permissions for 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) => {
console.log("[Rotation] Starting certificate rotation...");
try {
// 1. Fetch the Root CA
const rootCaResponse = await secretsManager.send(
new GetSecretValueCommand({ SecretId: ROOT_CA_SECRET })
);
const rootCa = JSON.parse(rootCaResponse.SecretString);
if (!rootCa.ROOT_CA_KEY || !rootCa.ROOT_CA_CERT) {
throw new Error("Root CA secret missing required keys");
}
console.log("[Rotation] Root CA retrieved successfully");
// 2. Parse Root CA
const caCert = forge.pki.certificateFromPem(rootCa.ROOT_CA_CERT);
const caKey = forge.pki.privateKeyFromPem(rootCa.ROOT_CA_KEY);
// 3. Generate new client key pair (2048-bit RSA)
console.log("[Rotation] Generating new client key pair...");
const clientKeys = forge.pki.rsa.generateKeyPair(2048);
// 4. Create client certificate
const clientCert = forge.pki.createCertificate();
clientCert.publicKey = clientKeys.publicKey;
clientCert.serialNumber = Date.now().toString(16);
// Set validity period
clientCert.validity.notBefore = new Date();
clientCert.validity.notAfter = new Date();
clientCert.validity.notAfter.setDate(
clientCert.validity.notBefore.getDate() + CERT_VALIDITY_DAYS
);
// Set subject (client)
clientCert.setSubject([
{ name: "commonName", value: COMMON_NAME }
]);
// Set issuer (Root CA)
clientCert.setIssuer(caCert.subject.attributes);
// Add extensions
clientCert.setExtensions([
{ name: "basicConstraints", cA: false },
{ name: "keyUsage", digitalSignature: true, keyEncipherment: true },
{ name: "extKeyUsage", clientAuth: true }
]);
// 5. Sign with Root CA private key
clientCert.sign(caKey, forge.md.sha256.create());
console.log("[Rotation] Client certificate signed successfully");
// 6. Convert to PEM format
const clientCertPem = forge.pki.certificateToPem(clientCert);
const clientKeyPem = forge.pki.privateKeyToPem(clientKeys.privateKey);
// 7. Update the client secret in Secrets Manager
await secretsManager.send(new PutSecretValueCommand({
SecretId: CLIENT_SECRET,
SecretString: JSON.stringify({
MTLS_KEY: clientKeyPem,
MTLS_CERT: clientCertPem
})
}));
console.log("[Rotation] Client certificate rotated successfully");
console.log(`[Rotation] New certificate valid until: ${clientCert.validity.notAfter.toISOString()}`);
return {
statusCode: 200,
body: JSON.stringify({
message: "Certificate rotated successfully",
validUntil: clientCert.validity.notAfter.toISOString(),
commonName: COMMON_NAME
})
};
} catch (error) {
console.error("[Rotation] Failed to rotate certificate:", error);
throw error;
}
};
Required npm dependency in package.json:
Lambda Environment Variables:
ROOT_CA_SECRET_ARN=arn:aws:secretsmanager:us-east-1:OAM_ACCOUNT_ID:secret:Prod/Arda/RootCA
CLIENT_SECRET_ARN=arn:aws:secretsmanager:us-east-1:OAM_ACCOUNT_ID:secret:Prod/NextJs/MtlsKeys
CERT_COMMON_NAME=Prod-NextJs-Client
4.2 Configure EventBridge Scheduler¶
- Navigate to Amazon EventBridge → Scheduler → Schedules
- Click Create schedule
- Configure:
- Name:
mtls-cert-rotation-prod - Schedule pattern: Recurring schedule
- Schedule expression:
rate(90 days) - Flexible time window: 15 minutes (allows AWS to optimize execution)
- Name:
- Target:
- Target API: AWS Lambda Invoke
- Lambda function: Select your rotation Lambda
- Retry policy:
- Maximum retries: 3
- Maximum event age: 1 hour
- Dead-letter queue: Configure an SQS queue for failed rotations
- Click Create schedule
4.3 Client Certificate Rotation Flow¶
The Next.js BFF automatically handles certificate rotation through:
- TTL-based refresh: The
getMtlsAgent()function refreshes certificates every 4 hours - Error-based retry: If a TLS error occurs, the agent forces a certificate refresh and retries
No application restart or deployment is required when certificates rotate.
Root CA Rotation (Advanced)¶
Caution
Root CA rotation is a high-risk operation that affects all clients. Schedule during a maintenance window and have a rollback plan ready.
Root CA rotation is required when:
- The Root CA certificate is expiring (typically after 10 years)
- The Root CA private key has been compromised
- Security policy requires periodic CA rotation
Prerequisites¶
- New Root CA generated and tested in non-production environment
- Runbook reviewed by security team
- Maintenance window scheduled
- Rollback plan confirmed and tested
Step 1: Generate New Root CA (OAM Account)¶
# Generate new Root CA key (use strong randomness)
openssl genrsa -out MyRootCA-v2.key 4096
# Generate new Root CA certificate
openssl req -x509 -new -nodes -key MyRootCA-v2.key -sha256 -days 3650 \
-out MyRootCA-v2.pem -subj "/CN=ArdaPrivateRootCA-v2"
# Initialize serial number
echo "01" > MyRootCA-v2.srl
# Store in Secrets Manager as a new secret
aws secretsmanager create-secret \
--name "Prod/Arda/RootCA-v2" \
--secret-string "{\"ROOT_CA_KEY\":\"$(cat MyRootCA-v2.key)\",\"ROOT_CA_CERT\":\"$(cat MyRootCA-v2.pem)\"}" \
--description "Root CA v2 for mTLS"
Step 2: Create Truststore Bundle (Both CAs)¶
API Gateway must trust both CAs during the transition period:
# Create bundle with both CAs
cat MyRootCA.pem MyRootCA-v2.pem > truststore-bundle.pem
# Verify bundle contains both certificates
openssl crl2pkcs7 -nocrl -certfile truststore-bundle.pem | openssl pkcs7 -print_certs -noout
# Should show 2 certificates
Step 3: Upload Bundle to S3 (Workload Account)¶
aws s3 cp truststore-bundle.pem s3://arda-mtls-truststore-prod/MyRootCA.pem \
--profile workload-account
Step 4: Wait for API Gateway Cache Refresh¶
API Gateway caches the truststore for up to 60 minutes.
Warning
Do NOT proceed until at least 60 minutes have elapsed and no TLS errors are observed.
Step 5: Issue New Client Certificate from New CA¶
# Generate new client key
openssl genrsa -out client-v2.key 2048
# Create CSR
openssl req -new -key client-v2.key -out client-v2.csr -subj "/CN=Prod-NextJs-Client"
# Sign with NEW Root CA
openssl x509 -req -in client-v2.csr -CA MyRootCA-v2.pem -CAkey MyRootCA-v2.key \
-CAserial MyRootCA-v2.srl -out client-v2.pem -days 365 -sha256
# Verify chain
openssl verify -CAfile MyRootCA-v2.pem client-v2.pem
# Expected: client-v2.pem: OK
Step 6: Update Client Secret in Secrets Manager¶
aws secretsmanager put-secret-value \
--secret-id "Prod/NextJs/MtlsKeys" \
--secret-string "{\"MTLS_KEY\":\"$(cat client-v2.key)\",\"MTLS_CERT\":\"$(cat client-v2.pem)\"}"
Step 7: Verify New Certificate Works¶
# Test with new certificate
curl --cert client-v2.pem --key client-v2.key https://api.arda.cards/health
# Expected: 200 OK
Step 8: Update Lambda to Use New Root CA¶
Update the Lambda environment variable:
Step 9: Remove Old CA from Bundle (After Validation Period)¶
Wait at least 7 days to ensure all cached certificates have been refreshed, then:
# Upload only the new CA
aws s3 cp MyRootCA-v2.pem s3://arda-mtls-truststore-prod/MyRootCA.pem \
--profile workload-account
Step 10: Archive Old Root CA¶
# Move old Root CA to archive
aws secretsmanager update-secret \
--secret-id "Prod/Arda/RootCA" \
--description "ARCHIVED - Replaced by RootCA-v2 on $(date +%Y-%m-%d)"
Verification¶
After completing the mTLS setup, verify all acceptance criteria are met:
Test 1: Reject Requests Without Certificate¶
Test 2: Reject Requests with Unknown CA¶
# Generate self-signed cert (not from our CA)
openssl req -x509 -newkey rsa:2048 -keyout rogue.key -out rogue.pem \
-days 1 -nodes -subj "/CN=RogueClient"
# Should return 403 Forbidden
curl --cert rogue.pem --key rogue.key https://api.arda.cards/health
# Expected: HTTP/2 403
Test 3: Accept Valid mTLS Request¶
# Should return 200 OK
curl --cert client.pem --key client.key https://api.arda.cards/health
# Expected: HTTP/2 200
Test 4: Default Endpoint Inaccessible¶
# Should fail to connect
curl -v https://abc123.execute-api.us-east-1.amazonaws.com/health
# Expected: DNS resolution failure or connection refused
Test 5: Verify CloudTrail Logging (NFR-2)¶
- Navigate to AWS CloudTrail console
- Filter events:
- Event source:
secretsmanager.amazonaws.com - Event name:
GetSecretValue
- Event source:
- Confirm:
- Amplify role ARN appears in recent events
- All access events are logged with timestamps
Configuring Bruno for mTLS Testing¶
Bruno is an open-source API client that supports mTLS client certificates. This section explains how to configure Bruno to test API Gateway endpoints protected by mTLS.
Prerequisites¶
Before configuring Bruno, ensure you have:
- The client certificate (
client.pem) and private key (client.key) from Phase 1 - Bruno installed (version 1.0 or later)
- A Bruno collection for your API
Option 1: GUI Configuration (Bruno App)¶
Step 1: Open Collection Settings¶
- Open your Bruno collection
- Click the gear icon (⚙️) next to the collection name
- Select Settings → Client Certificates
Step 2: Add Client Certificate¶
- Click Add Certificate
- Configure the following:
| Field | Value |
|---|---|
| Domain | api.arda.cards (your API domain) |
| Certificate Type | PEM |
| Certificate File | Browse to client.pem |
| Key File | Browse to client.key |
| Passphrase | Leave empty (unless your key is encrypted) |
- Click Save
Step 3: (Optional) Add Custom CA Certificate¶
If your system doesn’t trust the Root CA:
- Go to Bruno Preferences (⌘ + , on macOS)
- Navigate to Certificates
- Enable Use Custom CA Certificate
- Browse to
MyRootCA.pem - Click Save
Option 2: Collection Configuration File¶
For team sharing and version control, 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": ""
}
]
}
}
Note
Store certificate files in a certs/ directory within your collection folder. Add this directory to .gitignore to avoid committing sensitive keys.
Option 3: Bruno CLI with Environment Variables¶
For CI/CD pipelines or automated testing, use environment variables:
# Set certificate paths
export BRUNO_CLIENT_CERT_PATH="./certs/client.pem"
export BRUNO_CLIENT_KEY_PATH="./certs/client.key"
# Run Bruno CLI
bru run --env production
In your environment file (environments/production.bru):
vars {
baseUrl: https://api.arda.cards
certPath: \{\{process.env.BRUNO_CLIENT_CERT_PATH\}\}
keyPath: \{\{process.env.BRUNO_CLIENT_KEY_PATH\}\}
}
Option 4: Bruno with 1Password CLI¶
For secure local development, use the 1Password CLI (op) to inject certificates directly from your vault without storing them on disk.
Prerequisites¶
-
Install 1Password CLI:
-
Enable CLI integration in 1Password app:
- Open 1Password → Settings → Developer
- Enable “Integrate with 1Password CLI”
-
Sign in to 1Password CLI:
Step 1: Store Certificates in 1Password¶
Create a secure note or document in 1Password containing your mTLS credentials:
- Open 1Password and create a new Secure Note named
Arda mTLS Client Certificate - Add the following fields:
- client_cert: Paste the contents of
client.pem - client_key: Paste the contents of
client.key
- client_cert: Paste the contents of
- Note the vault and item path (e.g.,
Development/Arda mTLS Client Certificate)
Alternatively, use the CLI to create the item:
op item create \
--category="Secure Note" \
--title="Arda mTLS Client Certificate" \
--vault="Development" \
'client_cert[text]='$(cat client.pem) \
'client_key[text]='$(cat client.key)
Step 2: Create Certificate Files from 1Password¶
Before running Bruno, extract certificates to temporary files:
# Extract certificates from 1Password to temp files
op read "op://Development/Arda mTLS Client Certificate/client_cert" > /tmp/mtls-client.pem
op read "op://Development/Arda mTLS Client Certificate/client_key" > /tmp/mtls-client.key
# Set restrictive permissions
chmod 600 /tmp/mtls-client.key
Step 3: Launch Bruno with op run¶
Use op run to inject environment variables and launch Bruno:
Create a .env.mtls file with secret references:
# .env.mtls - 1Password secret references
BRUNO_CLIENT_CERT_PATH=/tmp/mtls-client.pem
BRUNO_CLIENT_KEY_PATH=/tmp/mtls-client.key
Or use a single command with inline secret injection:
# Extract certs and launch Bruno in one command
op read "op://Development/Arda mTLS Client Certificate/client_cert" > /tmp/mtls-client.pem && \
op read "op://Development/Arda mTLS Client Certificate/client_key" > /tmp/mtls-client.key && \
chmod 600 /tmp/mtls-client.key && \
open -a "Bruno"
Step 4: Bruno CLI with op run¶
For command-line testing, wrap the Bruno CLI with op run:
# Run Bruno CLI with 1Password-injected certificates
op run --env-file=.env.mtls -- bru run --env production
Or inline:
op read "op://Development/Arda mTLS Client Certificate/client_cert" > /tmp/mtls-client.pem && \
op read "op://Development/Arda mTLS Client Certificate/client_key" > /tmp/mtls-client.key && \
chmod 600 /tmp/mtls-client.key && \
BRUNO_CLIENT_CERT_PATH=/tmp/mtls-client.pem \
BRUNO_CLIENT_KEY_PATH=/tmp/mtls-client.key \
bru run --env production
Recommended: Shell Alias¶
Add this alias to your ~/.zshrc or ~/.bashrc for convenient access:
# ~/.zshrc
bruno-mtls() {
echo "🔐 Fetching mTLS certificates from 1Password..."
op read "op://Development/Arda mTLS Client Certificate/client_cert" > /tmp/mtls-client.pem
op read "op://Development/Arda mTLS Client Certificate/client_key" > /tmp/mtls-client.key
chmod 600 /tmp/mtls-client.key
if [ "$1" = "gui" ]; then
echo "🚀 Launching Bruno..."
open -a "Bruno"
else
echo "🚀 Running Bruno CLI..."
BRUNO_CLIENT_CERT_PATH=/tmp/mtls-client.pem \
BRUNO_CLIENT_KEY_PATH=/tmp/mtls-client.key \
bru "$@"
fi
}
# Usage:
# bruno-mtls gui # Launch Bruno GUI
# bruno-mtls run --env production # Run Bruno CLI
Reload your shell:
Cleanup (Optional)¶
For extra security, remove temporary certificate files after use:
Tip
Using 1Password CLI ensures certificates are never stored permanently on disk and requires biometric/password authentication before each use.
Testing with Bruno¶
Once configured, test the mTLS connection:
- Create a new request in your collection:
meta {
name: Health Check
type: http
seq: 1
}
get {
url: {{ baseUrl }}/health
body: none
auth: bearer
}
auth:bearer {
token: {{ accessToken }}
}
- Send the request — Bruno will automatically include the client certificate
- Verify success — You should receive a
200 OKresponse
Troubleshooting Bruno mTLS¶
| Issue | Solution |
|---|---|
UNABLE_TO_VERIFY_LEAF_SIGNATURE |
Add Root CA in Bruno Preferences → Certificates |
SSL certificate problem: unable to get local issuer certificate |
Same as above — custom CA not trusted |
ECONNRESET |
Check certificate/key file paths are correct |
| Certificate not being sent | Verify domain in certificate config matches request URL exactly |
Handshake failed |
Ensure certificate is not expired (openssl x509 -in client.pem -noout -dates) |
Security Considerations¶
Warning
Never commit private keys to version control.
- Local Development: Store certificates in a local
certs/directory and add to.gitignore - Team Sharing: Use a secure method (1Password, Vault) to share certificates
- CI/CD: Use secret management (GitHub Secrets, AWS Secrets Manager) to inject certificate paths