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.
- No standing access — every production session is time-limited, approved, and logged via PAM (MOD-046)
- Least privilege — JWT scopes and IAM policies bound to the minimum required; no role sees more than it needs
- All actions logged — immutable audit trail; the log is the control evidence
- Secrets in Secrets Manager — AWS Secrets Manager only; automatic rotation; no secrets in code, environment variables, or config files
- 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:
- Extract
Authorization: Bearer {token}header - Decode the JWT header to get
kid(key ID) - Fetch JWKS from
{issuerUrl}/.well-known/jwks.json— cached in-memory for 24 hours - Locate the signing key matching
kid - Verify RS256 signature
- Assert
exp> now - Assert
issmatches the expected Cognito pool URL for this environment - Assert
token_use="access"— reject ID tokens - Assert
client_idmatches the expected app client for this API - 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¶
| 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_core — app_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:
- Approval from a second authorised person
- Time-limited session (maximum 4 hours)
- Full session recording
- 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¶
- Customer opens app → app initiates Cognito hosted UI or direct SDK auth flow (PKCE)
- Cognito challenges with biometric (device-bound passkey via WebAuthn) or TOTP fallback
- On success, Cognito issues ID token + Access token + refresh token
- App stores tokens in secure device storage (Keychain / Android Keystore) — never in plain storage
- API calls carry
Authorization: Bearer {access_token} - Refresh token used to obtain new Access tokens silently — no re-auth required within the 30-day refresh window
- 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.