Series · Securing Nigerian banks on AWS · Part 3 of 3 ← Part 1: The threat picture (DSI ↗) · ← Part 2: The architecture · The operational implementation
From diagram to running platform
A diagram is not an architecture. An architecture is not a control. A control is not evidence. The work that converts the Part 2 topology into something an examiner can sign off on is the work this piece documents.
What follows is the implementation pattern we run for a Nigerian bank engaging us on a standard 60-day platform delivery. It assumes the architectural decisions in Part 2 are made — primary region in Frankfurt, DR in Cape Town, Direct Connect from Lagos via dual carriers, on-prem retains the core ledger. It does not re-litigate those decisions.
What it does is name the modules, show the configurations that decide whether the platform passes audit, and walk through the Friday-evening incident scenario from Part 1 — line by line — to show how the architecture catches and contains it.
Days 0–14: The landing zone
Everything starts with the AWS Organisation. Not a single AWS account that grows into a mess. An Organisation, with Organizational Units that match the operational boundary of the bank, and accounts that are explicitly scoped to one workload each.
The Terraform that lays this:
module "landing_zone" {
source = "[email protected]:cmdev/aws-landing-zone-banks.git//modules/baseline?ref=v1.4.2"
organization_id = var.organization_id
primary_region = "eu-central-1"
dr_region = "af-south-1"
ous = {
production = { name = "Production", scp = "scp-production-baseline" }
compliance = { name = "Compliance", scp = "scp-compliance-vault" }
security = { name = "Security", scp = "scp-security-services" }
shared = { name = "SharedSvc", scp = "scp-shared-services" }
}
accounts = {
"core-banking-prod" = { ou = "production", workload = "core-banking" }
"payments-prod" = { ou = "production", workload = "payments" }
"analytics-prod" = { ou = "production", workload = "analytics" }
"compliance-vault" = { ou = "compliance", workload = "vault" }
"log-archive" = { ou = "security", workload = "logs" }
"audit" = { ou = "security", workload = "audit" }
"shared-services" = { ou = "shared", workload = "shared" }
}
baseline_controls = {
enable_guardduty = true
enable_security_hub = true
enable_cloudtrail_lake = true
enable_macie = true
enable_iam_access_analyzer = true
enforce_kms_cmk = true
enforce_s3_block_public = true
enforce_mfa_for_console = true
}
}
The Service Control Policies on the Organisational Units are the structural defence. The scp-production-baseline denies actions that would weaken the posture — disabling GuardDuty, deleting CloudTrail, modifying the KMS key policy that protects customer data, granting IAM permissions to a principal outside the organisation. Even a fully-compromised principal in a production account cannot turn these off; the SCP denial happens above the principal's authority.
The first two weeks of the engagement are this. Nothing visible to the end user yet. The platform foundation, laid in code, version-controlled, reviewed by both teams.
The other piece that lands in this window is the Transit Gateway that becomes the routing fabric between Direct Connect and every workload VPC. The DX circuit itself should have been initiated weeks earlier — AWS publishes the full procurement walkthrough at AWS Direct Connect: Getting Started ↗ covering location selection, partner engagement, LOA-CFA issuance, and BGP bring-up. Four to twelve weeks of lead time is typical; the platform Terraform is ready to apply on day one of week three, but only useful if the cable is already lit. Per the AWS Direct Connect + Transit Gateway whitepaper ↗, a transit VIF on the DX Gateway terminates on the TGW, and each workload VPC attaches to it. The route tables enforce network-layer segmentation that complements the L7 VPC Lattice policies later.
resource "aws_ec2_transit_gateway" "core" {
description = "core-banking-mesh"
amazon_side_asn = 64512
default_route_table_association = "disable"
default_route_table_propagation = "disable"
dns_support = "enable"
vpn_ecmp_support = "enable"
tags = { Name = "tgw-core", workload = "platform" }
}
resource "aws_dx_gateway_association" "core" {
dx_gateway_id = aws_dx_gateway.primary.id
associated_gateway_id = aws_ec2_transit_gateway.core.id
allowed_prefixes = ["10.0.0.0/8"]
}
# One route table per workload group — no shared default.
resource "aws_ec2_transit_gateway_route_table" "by_workload" {
for_each = toset(["core-banking", "payments", "analytics", "vault", "shared"])
transit_gateway_id = aws_ec2_transit_gateway.core.id
tags = { Name = "rtb-${each.key}", workload = each.key }
}
# Analytics attachment: associate to its own table, propagate only to shared.
resource "aws_ec2_transit_gateway_route_table_association" "analytics" {
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.analytics.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.by_workload["analytics"].id
}
resource "aws_ec2_transit_gateway_route_table_propagation" "analytics_to_shared" {
transit_gateway_attachment_id = aws_ec2_transit_gateway_vpc_attachment.analytics.id
transit_gateway_route_table_id = aws_ec2_transit_gateway_route_table.by_workload["shared"].id
}
The deliberate move is default_route_table_association = "disable" and default_route_table_propagation = "disable". New attachments do not automatically inherit a route to everything else. The platform team writes the association and propagation rules explicitly, per workload, per direction. Analytics propagates to shared services. It does not propagate to ledger. The L3 path does not exist.
This is the configuration that makes the eventual VPC Lattice policy a second line of defence rather than the only one. If VPC Lattice misconfigures, network routing still blocks the cross-workload path. If TGW routes misconfigure, the L7 service policies still deny. Two layers, two failure modes, both have to fail for the architecture to fail open.
Days 14–28: Identity boundaries
The second phase is where the security posture starts to take its operational shape. Identity Center is provisioned. Permission sets are written for each operational role the bank will assign. The Active Directory connector is wired in. Every human who will ever touch the AWS console gets there through SAML, never with a static credential.
The permission set for a SOC analyst:
resource "aws_ssoadmin_permission_set" "soc_analyst" {
name = "SOCAnalyst"
instance_arn = local.sso_instance_arn
session_duration = "PT8H"
description = "Read-only access to security telemetry across all bank accounts"
inline_policy = data.aws_iam_policy_document.soc_analyst.json
}
data "aws_iam_policy_document" "soc_analyst" {
statement {
sid = "ReadSecurityTelemetry"
actions = [
"guardduty:Get*", "guardduty:List*",
"securityhub:Get*", "securityhub:List*", "securityhub:Describe*",
"cloudtrail:LookupEvents",
"cloudtrail-data:StartQuery", "cloudtrail-data:GetQueryResults",
"macie2:Get*", "macie2:List*",
"config:Get*", "config:List*", "config:Describe*",
"logs:Describe*", "logs:Get*", "logs:FilterLogEvents",
]
resources = ["*"]
}
statement {
sid = "DenyDestructiveActions"
effect = "Deny"
actions = ["iam:*", "guardduty:Delete*", "securityhub:Disable*", "cloudtrail:Delete*"]
resources = ["*"]
}
statement {
sid = "RequireMFA"
effect = "Deny"
not_actions = ["sts:AssumeRoleWithSAML", "sts:GetSessionToken"]
resources = ["*"]
condition {
test = "BoolIfExists"
variable = "aws:MultiFactorAuthPresent"
values = ["false"]
}
}
}
Three observations on this configuration. First — the explicit Deny on destructive actions. An analyst who is socially engineered into running a destructive API call has that call denied at the policy layer, regardless of what the rest of the permission set says. Second — the MFA condition. Any session without active MFA is rejected for every action except the SAML assumption itself. Third — the eight-hour session boundary. Credentials are short-lived by design.
We write one permission set per role: SOC analyst, on-call engineer, incident responder, platform operator, auditor, compliance officer, executive read-only. Each one is reviewed against the principle of least privilege for the role's actual operational needs — not what the role's title suggests.
Days 28–42: Detection, segmentation, evidence
The third phase is where the platform starts producing telemetry. GuardDuty is enabled across every account, with Runtime Monitoring for the EKS and EC2 workloads in payments and core-banking. Security Hub is set as the aggregation point, with custom Insights mapped to the CBN CSAT control framework. CloudTrail Lake is configured as the long-retention audit store, with a separate Trail in the log-archive account that the security OU cannot modify.
VPC Lattice is where lateral-movement defence becomes operational. Every service-to-service edge in the platform is declared explicitly:
resource "aws_vpclattice_service" "ledger_writer" {
name = "ledger-writer"
auth_type = "AWS_IAM"
custom_domain_name = "ledger-writer.internal.bank.local"
}
resource "aws_vpclattice_auth_policy" "ledger_writer" {
resource_identifier = aws_vpclattice_service.ledger_writer.arn
policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Sid = "AllowPaymentsAndCoreOnly"
Effect = "Allow"
Principal = {
AWS = [
"arn:aws:iam::${var.core_banking_account}:role/core-bank-svc",
"arn:aws:iam::${var.payments_account}:role/payments-svc"
]
}
Action = ["vpc-lattice-svcs:Invoke"]
Resource = "*"
Condition = {
StringEquals = {
"aws:PrincipalTag/workload" = ["core-banking", "payments"]
}
DateGreaterThan = {
"aws:CurrentTime" = "2026-01-01T00:00:00Z"
}
}
},
{
Sid = "DenyAnalyticsExplicitly"
Effect = "Deny"
Principal = "*"
Action = "*"
Resource = "*"
Condition = {
StringEquals = {
"aws:PrincipalTag/workload" = "analytics"
}
}
}
]
})
}
The explicit Deny on analytics-tagged principals matters. The architectural goal is that an analyst's compromised credentials cannot reach the ledger. The Allow statement above limits to core-banking and payments — but the explicit Deny ensures that even if a future configuration change inadvertently broadens the Allow, the analytics workload remains blocked. Defence in depth at the policy layer.
The S3 vault that holds the immutable compliance archive is provisioned with Object Lock in compliance mode, a separate KMS key owned by the compliance-vault account, and a bucket policy that restricts writes to a specific role assumed only by the backup process. The seven-year retention is baked in at the object level; no role in the bank has the permission to override it.
Days 42–60: Runbooks and CSAT evidence
The last phase is what makes the platform operational rather than provisioned. Detection rules without runbooks are noise. Audit data without queries is silence. The final two weeks of the engagement are spent writing the procedures the SOC will actually execute, and the queries the examiner will actually ask.
For CSAT evidence, the queries land in a documented set against CloudTrail Lake. Three representative examples:
-- Who provisioned the IAM role used in the incident, when, and from where?
SELECT
eventTime,
userIdentity.principalId AS who,
userIdentity.sessionContext.sessionIssuer.userName AS assumed_by,
requestParameters.roleName AS role_created,
sourceIPAddress,
userAgent,
awsRegion
FROM
$bank_audit_data_store
WHERE
eventName = 'CreateRole'
AND eventTime BETWEEN @from_date AND @to_date
AND requestParameters.roleName LIKE @role_pattern
ORDER BY
eventTime DESC;
-- Every privileged action in the last 24 hours, with attribution
SELECT
eventTime,
eventName,
userIdentity.principalId,
userIdentity.sessionContext.sessionIssuer.userName,
requestParameters,
sourceIPAddress
FROM
$bank_audit_data_store
WHERE
eventTime > current_timestamp - interval '24' hour
AND eventName IN (
'CreateUser', 'AttachUserPolicy', 'PutUserPolicy',
'CreateAccessKey', 'CreateRole', 'AttachRolePolicy',
'PutRolePolicy', 'AssumeRole'
)
AND userIdentity.sessionContext.attributes.mfaAuthenticated = 'true'
ORDER BY
eventTime DESC;
-- KMS key usage for the production CMK: every Encrypt and Decrypt call
SELECT
eventTime,
eventName,
userIdentity.principalId,
requestParameters.encryptionContext,
errorCode,
errorMessage
FROM
$bank_audit_data_store
WHERE
eventName IN ('Encrypt', 'Decrypt', 'GenerateDataKey')
AND requestParameters.keyId = @customer_data_cmk
AND eventTime BETWEEN @from_date AND @to_date
ORDER BY
eventTime DESC;
The bank's compliance officer gets these as a runbook, parameterised. When the examiner asks the question, the answer is a SQL execution against the audit store, returned in seconds. The examiner did not ask the bank to attest that the controls worked — they asked for evidence. The evidence is the query result.
The Friday-evening runbook: a step-by-step replay
The most useful test of any security architecture is whether it would catch the incident it was designed against. The Friday-evening pattern from Part 1 — anomalous lateral movement against a payments-handling role, with quiet exfiltration over a weekend — is the scenario this architecture is built to catch. Here is what the runbook looks like as it executes.
T+0:00:00 — GuardDuty surfaces a finding. The payments-svc role in the payments account makes an unusual number of sts:AssumeRole calls toward roles outside its normal pattern. GuardDuty's IAM behaviour model has been baselined for fourteen days; the call rate is more than three standard deviations above the role's normal envelope. Severity: HIGH.
T+0:00:12 — Security Hub aggregates the finding. EventBridge rule matches on severity >= 7.0 AND product = "GuardDuty" AND resource.tag:workload = "payments". Two destinations are triggered in parallel.
T+0:00:15 — Path A. EventBridge → SNS topic sec-pager-critical → PagerDuty. On-call SOC analyst is paged at home. Phone, push, SMS.
T+0:00:18 — Path B. EventBridge → Lambda auto-contain. The function snapshots the relevant RDS cluster (point-in-time), freezes the BankOperator permission set in IAM Identity Center (the role can still log in, but cannot assume any role in the payments account), and applies an emergency VPC Lattice deny rule that explicitly blocks payments-svc from invoking the ledger-writer service. This is automatic containment, no human in the loop. The blast radius stops here.
T+0:01:00 — SOC analyst acknowledges the page. Opens the incident channel. Pulls the GuardDuty finding detail.
T+0:03:00 — Analyst executes the containment-validation query against CloudTrail Lake:
SELECT eventTime, eventName, requestParameters, errorCode
FROM $bank_audit_data_store
WHERE userIdentity.sessionContext.sessionIssuer.userName = 'payments-svc'
AND eventTime > current_timestamp - interval '60' minute
AND (eventName LIKE 'Put%' OR eventName LIKE 'Update%' OR eventName LIKE 'Create%')
ORDER BY eventTime DESC;
The result lists every state-changing call the role has made in the last hour. The analyst can immediately see which calls succeeded before containment, which were denied, and what the attempted scope of the compromise was.
T+0:05:00 — Decision point. The analyst confirms (a) no successful writes to the ledger, (b) one suspicious PutObject to an internal S3 path that warrants investigation, (c) no DR replication has been affected. The contained state is verified.
T+0:08:00 — Aurora secondary in af-south-1 is verified as healthy and lag-free. The Route 53 ARC readiness check is green. If the analyst decides the primary region is compromised, failover is one routing-control flip away. They decide it is not. Primary stays primary.
T+0:15:00 — Sector notification. The bank's NigFinCERT liaison sends the indicators of compromise — the IP addresses GuardDuty surfaced, the AssumeRole pattern, the timing — to the sector intelligence-sharing channel. The pattern from Part 1 — the silence is the failure — does not repeat here. The bank reports.
T+0:30:00 — Forensics window opens. The RDS snapshot from T+0:00:18 is restored to a forensic account. The analyst can examine the state of the database immediately before the anomalous behaviour without touching production.
T+2:00:00 — Root cause is identified. A long-lived secret in a CI/CD pipeline had been exposed via a public repository commit by a contractor three weeks earlier. The credentials had been scraped, deployed against the bank's pipeline, and used to obtain the payments-svc role. The fix is rotation (immediate), credential scanning in CI (within 24 hours), and Macie configured to scan public repositories for the bank's secrets going forward.
T+24:00:00 — Post-incident report filed with NDIC and CBN. The full audit trail — every call, every actor, every artifact — is queryable from CloudTrail Lake. The breach-notification window from NDPA is met with hours to spare.
The architecture worked because every piece played its role. GuardDuty caught the anomaly. EventBridge routed it. Lambda contained it before a human could intervene. VPC Lattice meant the compromise could not reach the ledger. Identity Center meant the principal was attributable. CloudTrail Lake meant the evidence was complete. Multi-region meant a region-level failover was available if needed. The bank reported, because the platform's auto-generated indicators of compromise were ready to share.
What the architecture could not do was prevent the credential exposure in the first place. That is human process — and even with the best technical architecture, human process remains the perimeter that fails first. The architecture's job is to limit what that failure costs.
What we hand over at day 60
At day 60 the platform is in production with the workloads the engagement scoped. The handover documents include:
- Infrastructure as code — the full Terraform module set, in the bank's own repository, with documented variables and a CI pipeline that plans against staging on every PR
- The control catalogue — every CSAT control mapped to its Security Hub standard, the AWS service that implements it, the configuration that enforces it, and the query that produces evidence of it working
- The runbook library — incident response procedures for the top eight scenarios documented in the sector's published incident pattern, with the queries, the auto-containment actions, and the escalation paths
- The detection rules — the GuardDuty filters, the custom Security Hub Insights, the CloudWatch alarms, and the EventBridge routing logic, all version-controlled
- The CSAT evidence pack — pre-built CloudTrail Lake queries the compliance team can execute on demand, plus the standing reports that produce the quarterly examiner-facing summary
- The cost-monitoring dashboard — every service tagged, every workload attributable, with a CloudWatch dashboard that shows the platform's run rate and an anomaly-detection rule that pages on unexpected spend
The platform runs. The bank's own engineering team is trained on the operational layer. The handover is real.
Where this ends, and where it doesn't
This is the end of the AWS-architecture series. Three pieces — the threat picture (Part 1, on DSI), the architecture (Part 2), this operational implementation. Together they document what individual-bank cloud security can credibly be in 2026.
What this series does not document — and what the threat picture in Part 1 implicitly raises — is the sectoral coordination layer. Every individual bank running this architecture is stronger than every bank running the equivalent posture on legacy infrastructure. The sector as a whole is still not as strong as it could be, because the telemetry generated by each bank stays inside that bank.
The next series, when we publish it, will examine what changes when that telemetry becomes shared infrastructure — the sovereign Nigeria-hosted consortium platform model that is being discussed at the regulatory and inter-bank level. Until then, the architecture in this series is the right thing to build. It is the pattern we propose for Nigerian and African banks, and the same patterns are in production for our clients in Europe.
Series · Securing Nigerian banks on AWS ← Part 1: The threat picture (DSI ↗) · ← Part 2: The architecture · Part 3: The operational implementation
This is the architecture we design for Nigerian and other African banks. The same patterns are in production for our clients in Europe. Engagement enquiries: [email protected] · Cloud security services
