Skip to content

Multi-Account Isolation

Floci supports full per-account resource isolation out of the box. Resources created by one account are invisible to all others — no configuration flag required.

How It Works

Every incoming request carries an AWS SigV4 Authorization header. Floci reads the Access Key ID (AKID) from that header and resolves the account in this order:

  1. An exactly 12-digit AKID is used directly as the account ID.
  2. An active long-term IAM access key resolves to the account that owns it.
  3. A live STS temporary credential resolves to its recorded account.
  4. Any other key falls back to FLOCI_DEFAULT_ACCOUNT_ID.
Authorization: AWS4-HMAC-SHA256 Credential=111111111111/20260510/us-east-1/sqs/aws4_request, ...
                                            ^^^^^^^^^^^^
                                            12-digit AKID → account ID 111111111111

Once the account ID is determined, every storage read and write is transparently namespaced under it. An SQS queue named orders created by account 111111111111 is stored and retrieved as 111111111111/orders — completely separate from the same queue name under account 222222222222.

Same convention as LocalStack

This 12-digit AKID → account ID rule matches LocalStack's multi-account behavior, so existing multi-account test setups work without changes.

Long-Term IAM Credentials

Active access keys created through IAM route requests back to the account that owns the key. This lets a client keep using its generated AKIA… credentials instead of replacing the access key ID with a synthetic 12-digit account ID.

Account selection is AKID-based and is not proof that the caller possesses the corresponding secret. This extends Floci's existing local-emulator trust model for 12-digit AKIDs; do not treat account namespaces as a security boundary between mutually untrusted clients.

Temporary Credentials (AssumeRole)

STS temporary credentials are also routed to the correct account. When you call AssumeRole (or AssumeRoleWithWebIdentity, AssumeRoleWithSAML, GetFederationToken), Floci issues a temporary ASIA… access key and remembers which account it belongs to:

  • AssumeRole / web-identity / SAML / federation → the account in the role (or federated-user) ARN. Assuming arn:aws:iam::222222222222:role/Deployer from account 111111111111 yields credentials that resolve to 222222222222, so resources created with them land in account B's namespace.
  • GetSessionToken → the caller's account (these credentials carry no role), so they stay in the same account.
Account 111111111111 ──AssumeRole arn:aws:iam::222222222222:role/Deployer──▶ ASIA… temp key
                                                                              │
ASIA… temp key ──CreateTable orders──▶ stored as 222222222222/...::orders  ◀──┘

This makes the cross-account AssumeRole-then-provision pattern (e.g. CloudFormation deploying into a target account) work locally exactly as it does in AWS.

Default Behavior (Single Account)

If a credential is not a 12-digit account ID, an active IAM access key, or a live STS session (for example, test), requests resolve to the default account ID:

FLOCI_DEFAULT_ACCOUNT_ID=000000000000   # default

All ARNs and URLs use this value:

arn:aws:sqs:us-east-1:000000000000:my-queue
http://localhost:4566/000000000000/my-queue

You can change the default account ID without enabling per-request isolation:

FLOCI_DEFAULT_ACCOUNT_ID=123456789012

Enabling Multi-Account Isolation

Use 12-digit numeric access key IDs. The secret access key can be any non-empty string — Floci does not validate signatures by default.

AWS CLI

# Configure two named profiles
aws configure --profile account-a
# AWS Access Key ID: 111111111111
# AWS Secret Access Key: test

aws configure --profile account-b
# AWS Access Key ID: 222222222222
# AWS Secret Access Key: test

export AWS_ENDPOINT_URL=http://localhost:4566

# Create the same queue name under both accounts
aws sqs create-queue --queue-name orders --profile account-a
aws sqs create-queue --queue-name orders --profile account-b

# Each account sees only its own queue
aws sqs list-queues --profile account-a   # → .../111111111111/orders
aws sqs list-queues --profile account-b   # → .../222222222222/orders

AWS SDK (Java)

StaticCredentialsProvider accountA = StaticCredentialsProvider.create(
    AwsBasicCredentials.create("111111111111", "test"));

StaticCredentialsProvider accountB = StaticCredentialsProvider.create(
    AwsBasicCredentials.create("222222222222", "test"));

SqsClient clientA = SqsClient.builder()
    .endpointOverride(URI.create("http://localhost:4566"))
    .region(Region.US_EAST_1)
    .credentialsProvider(accountA)
    .build();

SqsClient clientB = SqsClient.builder()
    .endpointOverride(URI.create("http://localhost:4566"))
    .region(Region.US_EAST_1)
    .credentialsProvider(accountB)
    .build();

// Both calls succeed, resources are fully isolated
clientA.createQueue(r -> r.queueName("orders"));
clientB.createQueue(r -> r.queueName("orders"));

AWS SDK (Python)

import boto3

def client(service, account_id):
    return boto3.client(
        service,
        endpoint_url="http://localhost:4566",
        region_name="us-east-1",
        aws_access_key_id=account_id,      # 12-digit → account ID
        aws_secret_access_key="test",
    )

sqs_a = client("sqs", "111111111111")
sqs_b = client("sqs", "222222222222")

sqs_a.create_queue(QueueName="orders")
sqs_b.create_queue(QueueName="orders")

print(sqs_a.list_queues()["QueueUrls"])  # [".../111111111111/orders"]
print(sqs_b.list_queues()["QueueUrls"])  # [".../222222222222/orders"]

ARNs Include the Correct Account ID

Floci embeds the resolved account ID in every ARN it generates:

arn:aws:sqs:us-east-1:111111111111:orders
arn:aws:lambda:us-east-1:222222222222:function:my-fn
arn:aws:s3:::my-bucket                         # S3 ARNs are account-agnostic

Isolation Scope

All services that use StorageFactory participate in account isolation automatically. This covers every service in Floci — SQS, SNS, S3, DynamoDB, Lambda, SSM, Secrets Manager, KMS, Kinesis, EventBridge, Cognito, RDS, ElastiCache, OpenSearch, MSK, and more.

AWS Organizations sits on top of this model: the organization's state lives in the management account's namespace, member accounts created with CreateAccount are immediately usable as 12-digit access key IDs, and — with IAM enforcement plus FLOCI_SERVICES_ORGANIZATIONS_SCP_ENFORCEMENT_ENABLED — service control policies attached in the organization constrain what member-account identities may do.

Background workers (Lambda event-source pollers, DynamoDB TTL sweeper, MSK readiness poller, OpenSearch readiness poller) iterate across all accounts internally and route writes back to the originating account. No cross-account data leaks through these async paths.

S3 exception — global bucket namespace. S3 buckets are isolated per account by default like every other service, but real S3 bucket names are globally unique. Set FLOCI_SERVICES_S3_GLOBAL_BUCKET_NAMESPACE=true to make bucket/object resolution span every account's partition, so a bucket created in one account is visible cross-account — needed when a workload writes a bucket in one account and reads it from another (see S3).

Signature Validation

By default Floci does not verify SigV4 signatures. Only the access key ID matters for account resolution, so the secret access key can be any non-empty string.

FLOCI_AUTH_VALIDATE_SIGNATURES verifies the SigV4 signature of every signed request, for every service, without evaluating any policy, and leaves unsigned requests alone:

  • S3 verifies every placement (Authorization header, presigned URL and presigned POST); see S3 Signature Verification.
  • Every other service verifies the Authorization header or a SigV4 presigned query string. A nonblank Authorization header takes precedence. The canonical request is rebuilt from the request as it arrived, with the path encoded the way AWS signers encode it outside S3 and the body's own SHA-256 as the payload hash. A refusal comes back in the protocol the request used, with the Query and REST-XML name first (Route 53, CloudFront and S3 Control answer in XML) and the JSON, CBOR and REST-JSON name second: SignatureDoesNotMatch or InvalidSignatureException for a signature that does not verify or is more than 15 minutes from server time, InvalidClientTokenId or UnrecognizedClientException for an access key Floci does not know, and IncompleteSignature or IncompleteSignatureException for an Authorization header missing a part. Presigned requests use their X-Amz-Expires lifetime (1–604800 seconds) and may be at most 15 minutes in the future; credential scope date or service mismatches are refused. A signed x-amz-content-sha256 payload marker is honored; otherwise the actual body is hashed. SigV4a is not verified here. API Gateway execute-api requests and AppSync GraphQL keep their own verifiers; API Gateway documents its IAM authorization.

An expired presigned query reports Signature expired: <signed-date> is now earlier than <cutoff>. The cutoff is the server time minus X-Amz-Expires, so the message describes the URL's lifetime. An expired header signature uses the same wording with a cutoff 15 minutes before server time. Query and header signatures more than 15 minutes ahead report Signature not yet current: <signed-date> is still later than <cutoff>, with a cutoff 15 minutes after server time. When the receiving service has one registered signing scope, a mismatch reports Credential should be scoped to correct service: '<service>'. Services with multiple accepted scopes retain the generic mismatch message because their aliases do not identify a canonical signing name.

To verify signatures:

FLOCI_AUTH_VALIDATE_SIGNATURES=true
FLOCI_AUTH_PRESIGN_SECRET=your-secret   # for pre-signed URL verification

Once it is on, every signed request must use credentials Floci can verify: test/test, or credentials created through Floci IAM or STS (including the session token for STS credentials). A synthetic 12-digit account key fails on every service, with 403 InvalidAccessKeyId from S3 header-signed requests, 403 SignatureDoesNotMatch from S3 presigned POSTs, and 403 InvalidClientTokenId or 403 UnrecognizedClientException elsewhere. For multi-account access, use IAM or STS credentials for the target account; test/test routes to FLOCI_DEFAULT_ACCOUNT_ID. The same applies to workloads Floci launches: a container started without a role, such as a Lambda function with no execution role, is given placeholder credentials that do not verify, so give it a role.

When validate-signatures is false (the default), S3 signatures are verified only under FLOCI_SERVICES_S3_ENFORCE_AUTH, and no other service verifies them. Account routing remains AKID-based regardless of this setting.

Persistence and Account Isolation

Storage keys are namespaced per account at the persistence layer. When using persistent, hybrid, or wal storage modes, each account's data is stored under its own key prefix. Restarting Floci restores each account's resources independently.

Configuration Reference

Variable Default Description
FLOCI_DEFAULT_ACCOUNT_ID 000000000000 Account ID used when the AKID does not resolve directly or through a stored credential
FLOCI_DEFAULT_REGION us-east-1 Region used when not derivable from the Authorization header
FLOCI_AUTH_VALIDATE_SIGNATURES false Verify the SigV4 signature of every signed request, for every service, without evaluating policies