Skip to content

Security architecture

Related: Jurisdiction runtime model · Interface contracts · MOD-044 JWT RBAC · MOD-045 Secrets management · ADR-026 · ADR-027


Zero trust principles

Five rules implementing AP-004 (Security by design). These are hard constraints, not guidelines.

  1. No standing access — every production session is time-limited, approved, and logged via PAM (MOD-046)
  2. Least privilege — JWT scopes and IAM policies bound to the minimum required; no role sees more than it needs
  3. All actions logged — immutable audit trail; the log is the control evidence
  4. Secrets in Secrets Manager — AWS Secrets Manager only; automatic rotation; no secrets in code, environment variables, or config files
  5. Verify every request — every cross-boundary call is authenticated; no implicit trust between services

mTLS is not used for Lambda-to-Lambda communication. Lambda is ephemeral — certificate lifecycle management for short-lived functions is impractical and unnecessary given IAM provides stronger, auditable authorisation. See inter-service authentication below.


JWT token structure

JWT is issued by AWS Cognito and validated by MOD-044 (JWT RBAC module) at every API boundary crossing. Tokens are RS256-signed; the signing key is Cognito-managed.

Customer Access token

Issued by bank-customers-{env} pool on successful authentication. This is the token validated by API Gateway and Lambda authorizers. Valid 1 hour; refresh token valid 30 days.

{
  "sub":                  "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "iss":                  "https://cognito-idp.ap-southeast-2.amazonaws.com/ap-southeast-2_XXXXXXXX",
  "client_id":            "1abc2def3ghi4jkl5mno6pqr",
  "token_use":            "access",
  "scope":                "openid bank-api/read bank-api/transact",
  "auth_time":            1745000000,
  "exp":                  1745003600,
  "iat":                  1745000000,
  "jti":                  "f7e8d9c0-b1a2-3456-7890-abcdef123456",
  "username":             "a1b2c3d4-e5f6-7890-abcd-ef1234567890",
  "cognito:groups":       ["customers"],
  "custom:user_id":       "u-11112222-3333-4444-5555-666677778888",
  "custom:party_id":      "p-aaaabbbb-cccc-dddd-eeee-ffff00001111",
  "custom:jurisdiction":  "NZ"
}
Claim Source Used by
sub Cognito — maps to access.user_identities.cognito_sub Token validation
custom:user_id Set at registration — access.user_identities.user_id All modules as the stable user anchor
custom:party_id Set at KYC — party.parties.party_id Modules that look up party/customer data
custom:jurisdiction Set at onboarding — banking.customer_relationships.jurisdiction Jurisdiction branching in all modules
cognito:groups Cognito group membership Scope enforcement in MOD-044
scope OAuth scopes granted to the app client API endpoint authorisation
token_use Always "access" Validation — reject ID tokens at API boundary

Not in the token: kyc_status, cdd_tier, aml_risk_rating, pep_flag. These change asynchronously. A stale JWT claim must never be used to bypass a compliance GATE — read from source at the enforcement point.

Customer ID token

Issued alongside the Access token. Used by the client application only (to populate the UI, display the user's name, etc.). Not validated by Lambda authorizers — API Gateway rejects ID tokens at the boundary.

Contains the same custom attributes plus standard OIDC claims (email, phone_number, name). The app reads custom:user_id and custom:party_id from the ID token to initialise its local state.

Staff Access token

Issued by bank-staff-{env} pool. Valid 1 hour (agent sessions); refresh token valid 8 hours (a standard working day — staff must re-authenticate daily).

{
  "sub":              "b9c8d7e6-f5a4-3210-fedc-ba9876543210",
  "iss":              "https://cognito-idp.ap-southeast-2.amazonaws.com/ap-southeast-2_YYYYYYYY",
  "client_id":        "7stu8vwx9yza0bcd1efg2hij",
  "token_use":        "access",
  "scope":            "openid bank-admin/read bank-admin/write",
  "auth_time":        1745000000,
  "exp":              1745032000,
  "iat":              1745000000,
  "jti":              "a0b1c2d3-e4f5-6789-0abc-def012345678",
  "username":         "ross.taylor@bank.nz",
  "cognito:groups":   ["compliance_officer"],
  "custom:staff_id":  "STAFF-0042"
}

Staff tokens carry no custom:jurisdiction — staff operate across both jurisdictions per their role. cognito:groups is the authorisation signal; MOD-044 maps groups to permitted scopes and endpoints.

OAuth scopes

Two resource servers — customer API and admin API.

Customer resource server (bank-api)

Scope Grants access to
bank-api/read Account balances, transaction history, profile data, asset positions
bank-api/transact Payment initiation, automation rules, account management
bank-api/consent Consent management, Open Banking authorisation flows

Admin resource server (bank-admin)

Scope Grants access to
bank-admin/read Customer 360 view, case history, account data (masked per role)
bank-admin/write Customer record updates, case actions, account controls
bank-admin/compliance AML case management, sanctions adjudication, SAR submission
bank-admin/pam Privileged access operations — requires PAM session (MOD-046)

Token validation (MOD-044)

Every API Gateway Lambda authorizer follows this sequence:

  1. Extract Authorization: Bearer {token} header
  2. Decode the JWT header to get kid (key ID)
  3. Fetch JWKS from {issuerUrl}/.well-known/jwks.json — cached in-memory for 24 hours
  4. Locate the signing key matching kid
  5. Verify RS256 signature
  6. Assert exp > now
  7. Assert iss matches the expected Cognito pool URL for this environment
  8. Assert token_use = "access" — reject ID tokens
  9. Assert client_id matches the expected app client for this API
  10. Extract claims and return an IAM policy document permitting or denying the request

A token that fails any assertion returns HTTP 401. A valid token with insufficient scope returns HTTP 403 with error_code: INSUFFICIENT_SCOPE.

Reading claims in a Lambda

Claims are injected into the Lambda event by API Gateway after authorizer validation:

claims = event["requestContext"]["authorizer"]["claims"]

user_id      = claims["custom:user_id"]
party_id     = claims["custom:party_id"]
jurisdiction = claims["custom:jurisdiction"]
groups       = claims.get("cognito:groups", "").split(",")
scope        = claims.get("scope", "")

Secrets management

AWS Secrets Manager only. HashiCorp Vault is not used. All secrets — database credentials, API keys, private keys, third-party service credentials — live in Secrets Manager. Violations are detected by static analysis in CI (no secret literals in source, no secrets in environment variables).

MOD-045 owns the rotation automation. KMS key bank/operational encrypts all secrets at rest.

Naming convention

/bank/{env}/{domain}/{secret-name}
Segment Values
{env} prod · uat · dev
{domain} core · kyc · aml · payments · credit · risk · platform · app · shared
{secret-name} Descriptive — see examples below

Examples:

Path Content
/bank/prod/core/neon-app-connection Neon connection string for bank_coreapp_user role
/bank/prod/kyc/neon-app-connection Neon connection string for bank_kyc
/bank/prod/platform/snowflake-private-key RSA private key (PKCS8, PEM) for Snowflake key-pair auth
/bank/prod/shared/cognito-customer-pool-id Cognito user pool ID (non-secret but managed here for consistency)
/bank/prod/kyc/dia-dvs-api-key DIA DVS identity verification API key
/bank/prod/kyc/illion-api-key Illion credit bureau API key
/bank/prod/payments/swift-api-key SWIFT connectivity credentials
/bank/prod/payments/akahu-client-secret Akahu OAuth client secret (MOD-100)

Secret format

Database connection secrets use a structured JSON format:

{
  "host":        "ep-quiet-forest-123456.ap-southeast-2.aws.neon.tech",
  "port":        5432,
  "database":    "bank_core",
  "username":    "core_app_user",
  "password":    "...",
  "pooler_url":  "postgres://core_app_user:...@ep-quiet-forest-123456-pooler.ap-southeast-2.aws.neon.tech/bank_core?sslmode=require"
}

Lambdas connect via pooler_url (PgBouncer transaction pooling). Migration jobs connect directly via host/port.

Retrieval pattern

Secrets are fetched at Lambda cold start and cached for the instance lifetime. On connection failure, clear the cache and re-fetch — Secrets Manager rotation must not break running instances.

import boto3, json
from functools import lru_cache

_sm = boto3.client("secretsmanager")

@lru_cache(maxsize=None)
def _fetch(path: str) -> dict:
    return json.loads(_sm.get_secret_value(SecretId=path)["SecretString"])

def get_secret(path: str, bust_cache: bool = False) -> dict:
    if bust_cache:
        _fetch.cache_clear()
    return _fetch(path)

Never log secret values. Never pass secrets as function arguments across Lambda invocations. Never write secrets to EventBridge events, DLQs, or S3.

Rotation policy

Secret type Rotation interval Method
Neon database passwords 90 days Automated — MOD-045 Lambda rotation function calls Neon API then updates Secrets Manager
Snowflake RSA key pairs 1 year Manual — generate new key pair, update Snowflake user, update secret
Third-party API keys 90 days Manual where provider supports key rotation
JWT signing keys Cognito-managed Automatic — Cognito handles key rotation; JWKS cache handles the transition

Inter-service authentication

Call pattern Authentication Notes
Customer → API Gateway Cognito JWT (Access token) Validated by Lambda authorizer (MOD-044)
Staff → back-office API Cognito JWT (Access token, staff pool) Same authorizer; different pool and scopes
Intra-domain Lambda → Lambda AWS IAM role trust Calling Lambda's execution role has lambda:InvokeFunction permission on target
Cross-domain Lambda → Lambda IAM role trust via MOD-075 (internal API gateway) JWT not required; IAM resource policy on MOD-075 controls cross-domain access
Lambda → Neon Postgres Password (from Secrets Manager) Via PgBouncer connection string; TLS enforced by Neon
Lambda → Snowflake RSA key-pair auth Private key from Secrets Manager; Snowflake verifies against registered public key
Lambda → AWS services (EventBridge, S3, Secrets Manager, etc.) IAM execution role Least-privilege policies per domain (MOD-104)
Lambda → external providers (DVS, illion, Akahu, SWIFT, etc.) API key or OAuth 2.0 client credentials Credentials from Secrets Manager; TLS required

No mTLS in Lambda-to-Lambda paths. TLS is provided by the transport layer (HTTPS/TLS 1.3 for all HTTP calls; Neon enforces TLS on all connections). IAM provides the authorisation layer — stronger and more auditable than certificate-based mutual auth for ephemeral functions.


Privileged access management

No engineer has standing access to production databases or infrastructure. All production sessions require:

  1. Approval from a second authorised person
  2. Time-limited session (maximum 4 hours)
  3. Full session recording
  4. Automatic termination on expiry

Session records retained for 7 years (regulatory retention). Implemented by MOD-046.


Audit trail

Every action — customer, agent, system decision, API call — written to an immutable append-only audit log. No DELETE or UPDATE permitted on audit tables by any DB role including DBA. Enforced at the Postgres role level (no DELETE or UPDATE grants on audit tables) and by Postgres row-level triggers.

Audit log replicated to Snowflake via MOD-042 for analysis and regulatory evidence production.

MTTD target for anomalous access via MOD-076 observability alerts: ≤ 5 minutes (NFR-023).


Encryption

Data state Standard
Data at rest — Neon Postgres AES-256 — Neon platform default; column-level encryption for PII_HIGH fields via application layer
Data at rest — Snowflake AES-256 — Snowflake platform default; Dynamic Data Masking for PII columns per MOD-102
Data at rest — S3 KMS CMK encryption — key per data classification (bank/pii, bank/financial, bank/operational)
Data at rest — Secrets Manager KMS CMK bank/operational
Data in transit TLS 1.3 minimum — all Lambda HTTP calls, Neon connections (enforced server-side), Snowflake connections
PII column-level encryption Application-layer AES-256 for PII_HIGH fields (tax IDs, passport numbers, biometric references) — ciphertext stored in Postgres; plaintext never logged

Customer authentication flow

  1. Customer opens app → app initiates Cognito hosted UI or direct SDK auth flow (PKCE)
  2. Cognito challenges with biometric (device-bound passkey via WebAuthn) or TOTP fallback
  3. On success, Cognito issues ID token + Access token + refresh token
  4. App stores tokens in secure device storage (Keychain / Android Keystore) — never in plain storage
  5. API calls carry Authorization: Bearer {access_token}
  6. Refresh token used to obtain new Access tokens silently — no re-auth required within the 30-day refresh window
  7. On refresh token expiry or explicit logout, all tokens are invalidated via Cognito GlobalSignOut

High-value operations (payments above threshold, profile changes) require step-up authentication — a fresh biometric challenge — before the operation proceeds. Step-up is enforced by the relevant Lambda, not just by the token. See app.customer_sessions.mfa_completed in the SD08 data model.