Skip to content

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.


Perform all steps in Phase 1 in the OAM Account.

Run these commands locally or in AWS CloudShell to generate your chain of trust.

Terminal window
# 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 CA
openssl x509 -req -in client.csr -CA MyRootCA.pem -CAkey MyRootCA.key -CAserial MyRootCA.srl -out client.pem -days 365 -sha256

Caution: The Root CA private key is the foundation of your mTLS security. Store it immediately and delete local copies.

  1. Navigate to AWS Secrets Manager.
  2. Store a new secret → Other type of secret.
  3. Add Key/Value pairs:
    • ROOT_CA_KEY: (Content of MyRootCA.key)
    • ROOT_CA_CERT: (Content of MyRootCA.pem)
  4. Name the secret: {Environment}/Arda/RootCA (e.g., Prod/Arda/RootCA).
  5. Add a description: Root CA for mTLS - DO NOT DELETE.
  6. Copy the Secret ARN.
  7. 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”
  1. Store a new secret → Other type of secret.
  2. Add Key/Value pairs:
    • MTLS_KEY: (Content of client.key)
    • MTLS_CERT: (Content of client.pem)
  3. Name the secret: {Environment}/NextJs/MtlsKeys (e.g., Prod/NextJs/MtlsKeys).
  4. Copy the Secret ARN (needed in Phase 3).

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.

  1. 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
  2. Upload the public MyRootCA.pem file from Phase 1.1.
  3. 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.

  1. Go to API Gateway → APIs → Select your API → Settings.
  2. Default endpoint: Set to Disabled.
  3. Save.
Terminal window
# 1. Verify truststore is uploaded
aws s3 ls s3://arda-mtls-truststore-prod/MyRootCA.pem
# 2. Verify certificate chain
openssl verify -CAfile MyRootCA.pem client.pem
# Expected: client.pem: OK
  1. Navigate to API Gateway → Custom domain names.
  2. Select your existing Custom Domain → Click Edit.
  3. Mutual TLS authentication: Toggle ON.
  4. Truststore URI: Paste the S3 URI from step 2.1.
  5. Save changes.

Warning: This immediately enforces mTLS. External clients without the certificate will lose access.


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.

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 support
let 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;
}
}
Terminal window
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
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" });
}
}

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:

Terminal window
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

Required npm dependency:

{
"dependencies": {
"@aws-sdk/client-secrets-manager": "^3.0.0",
"node-forge": "^1.3.1"
}
}
  1. Navigate to Amazon EventBridge → Scheduler → Schedules → Create schedule.
  2. Configure:
    • Name: mtls-cert-rotation-prod
    • Schedule expression: rate(90 days)
    • Flexible time window: 15 minutes
  3. Target: AWS Lambda Invoke → select your rotation Lambda.
  4. Retry policy: Maximum retries 3, Maximum event age 1 hour.
  5. 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.


Test 1: Reject Requests Without Certificate

Section titled “Test 1: Reject Requests Without Certificate”
Terminal window
# Should return 403 Forbidden
curl -v https://api.arda.cards/health
Terminal window
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 403
Terminal window
curl --cert client.pem --key client.key https://api.arda.cards/health
# Expected: HTTP/2 200
Terminal window
curl -v https://abc123.execute-api.us-east-1.amazonaws.com/health
# Expected: DNS resolution failure or connection refused

  1. Open your Bruno collection → gear icon → SettingsClient Certificates.

  2. Click Add Certificate and fill in:

    FieldValue
    Domainapi.arda.cards
    Certificate TypePEM
    Certificate FilePath to client.pem
    Key FilePath to client.key
  3. If your system does not trust the Root CA, go to Bruno Preferences → Certificates → enable Use Custom CA Certificate → select MyRootCA.pem.

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.

Extract certificates to temporary files before launching Bruno:

Terminal window
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

Then run the Bruno CLI:

Terminal window
BRUNO_CLIENT_CERT_PATH=/tmp/mtls-client.pem \
BRUNO_CLIENT_KEY_PATH=/tmp/mtls-client.key \
bru run --env production
IssueSolution
UNABLE_TO_VERIFY_LEAF_SIGNATUREAdd Root CA in Bruno Preferences → Certificates
ECONNRESETCheck certificate and key file paths are correct
Certificate not being sentVerify domain in certificate config matches request URL exactly
Handshake failedCheck certificate expiry: openssl x509 -in client.pem -noout -dates