AWS Builder Center

Data isolation & security in multi-tenant AI agent for SaaS

How to propagate user identity through AI agent all the way from front-end login to the data layer for row level security and access control based on roles to implement multi-tenant data isolation and security in SaaS agentic application. This is based on sample application with Strands agent on Amazon Bedrock AgentCore with Aurora PostgreSQL database.

Tenant data isolation for data serving applications in SaaS has a well-established pattern. But what about agentic AI data serving applications?
  • How to make it such that the same chatbot query "Show me our top 30 customers" from 2 different tenants yield correctly different answer based on data that truly belong to each tenant, while being powered by the same database and same infrastructure (pool model )?
  • How to make it such that agents serving users with admin roles have access to certain agentic tools, such as modifying data, while ensuring regular users can't access those tools?
This blog posts focuses on implementing multi-tenancy data isolation and role access segregation for AI data agents in Amazon Bedrock AgentCore , an AWS service for production AI agents that supports agent frameworks of your choice and a broad range of LLMs hosted in or out of AWS.

The scenario and the architecture

In this sample implementation, I have a single-page application (SPA) hosted in Amplify Hosting  that is wired with Amazon Cognito  for identity and authentication. The application provides chatbot that is powered by Anthropic Claude model  hosted in Amazon Bedrock  and Strands  agent running in AgentCore Runtime , a serverless agent hosting environment that supports Strands, LangGraph, Pipecat, and many others.
The agent can access data hosted in Amazon Aurora PostgreSQL  through Python code in AWS Lambda functions that are made available as MCP tools to the agent through AgentCore Gateway . See the architecture below.
Multi-tenant data agent architecture
Multi-tenant data agent architecture
The database implements pool model where all data across all tenants are stored in a single database. Tenants share tables while segregating data by tenant identifier, like account_idcolumn. This method can benefit in terms of infrastructure cost since everything is hosted into one database. It also benefits from less operational burden for update, patching, and backup, while also minimizes database configuration drift across tenants. However, if the security is not done properly, data can leak cross tenants. This blog post focuses on mitigating this, especially when the data is accessed by user through an AI agent.
This sample application is available both in demo mode that deploys everything and in workshop mode where you follow step-by-step instruction to deploy for better learning experience. The source code is available in AWS code sample Github repository  while the step-by-step workshop can be viewed at this page.  The workshop is designed to be run at AWS-event with live sandbox environment. If you need that AWS-event workshop, please reach out to AWS Contact Us  and state that you need to run workshop "Agentic Analytics for Multi-tenant SaaS with AgentCore" with ID 173b87a9-7267-4478-b7ec-0cd16a7cb520

Propagating identity and access

The ultimate goal is to propagate the user identity (which tenant they belong to, what role do they have) all the way to the data access layer. Making the AI agent the principal for the data access without propagating user identity is an anti-pattern for this problem as it can lead to confused deputy problem . For example, a user from tenant 'A' can impersonate as tenant 'B' and trick the AI agent to serve data of tenant 'B'. Therefore, the techniques I implement here are:
  1. propagating the user identity all the way to data layer and implement row-level security at the PostgreSQL  level;
  2. enforcing back-end layers to only trust claim that user 'A' is really user 'A' from tenant 'A' by looking at the tenant and role claims in validated JWT (JSON web tokens) resulted from the authentication process, instead of letting AI agent to infer it (prone to injection/impersonation).

Authentication and authorization

Firstly, the sample application has users already created in Amazon Cognito user pool, with 2 tenants and 4-5 users per tenant with different roles. Each user has custom attributes custom:account_id that represents the tenant ID and custom:role that represents the user role (e.g. rental_admin, staff, or analyst).
While the sample application uses Amazon Cognito, the concept still applies with other OIDC-supporting identity providers too. OIDC is based on OAuth where a successful authentication will result in both ID token and access token, both in the format of JWT. ID token tells who the person is, along with custom attributes such as the 2 attributes above. Access token is used to access the back-end layer, which in this case is AgentCore Runtime configured with inbound JWT authorizer . 
Now that AgentCore Runtime accepts the JWT access token, the custom attributes are only there in the ID token, not the access token, while it does need those information for later security measures such as RLS. The solution is to use Cognito pre token generation  with event version V2_0 that will inject the custom attributes to access token.
In this sample application, when user logs in, the SPA running in the browser receives the ID token and access token. The SPA then invokes AgentCore Runtime directly using using HTTPS requests (see documentation ). However, with the recent support  (as per June 2026) of AgentCore Runtime as a target for AgentCore Gateway, it is a better practice to front AgentCore Runtime with AgentCore Gateway . This can be further made secure by attaching AWS Web Application Firewall (AWS WAF)  to the AgentCore Gateway as per this recent launch . The sample application will be updated to follow these best practices.

JWT validation and propagation

But, how do we prevent bad actors from tampering with their JWT token, such as by changing its custom:account_id to other tenant to maliciously gain unauthorized data access? This is why you should validate the JWT token. A JWT token consists of header, payload, and signature. A validation code can fetch the public key from the .well-known/openid-configuration discovery URL and verify that the signature matches, proving no tampering. This way the application can trust the custom:account_id and custom:role decoded from the payload. The validation logic can also validate the iss (issuer) and aud (audiences). The good news is that, AgentCore Identity can do the validation for you,  freeing your agent code from having to do it. AgentCore Identity works with AgentCore Runtime inbound JWT authorization.
The following CloudFormation  code snippet shows the configuration for inbound JWT authorizer with AgentCore runtime
1
2
3
4
5
AuthorizerConfiguration:
CustomJWTAuthorizer:
DiscoveryUrl: !Sub https://cognito-idp.${AWS::Region}.amazonaws.com/${CognitoUserPoolId}/.well-known/openid-configuration
AllowedClients:
- !Ref CognitoClientId
In there you see that I set the Cognito issuer URL as the discovery URL like this https://cognito-idp.us-east-1.amazonaws.com/us-east-1_nnnnnnnnn/.well-known/openid-configuration. This can work with other providers as well.
Now how should the agent in the AgentCore Runtime propagates the JWT to AgentCore Gateway and eventually the tools that access data? This is actually done by the agent code inside the AgentCore Runtime as shown in the code snippet below.
1
2
3
4
5
6
7
def create_transport():
return streamablehttp_client(
GATEWAY_URL,
headers={"Authorization": f"Bearer {access_token}"}
)

mcp_client = MCPClient(create_transport)
Since AgentCore Gateway and the tools behind it are accessed through MCP, the JWT is propagated as the header of the MCP calls. The access_token here refers to the JWT access token.
AgentCore Gateway will have its own JWT validation process with its inbound JWT authorizer. The CloudFormation configuration for the Gateway also includes CustomJWTAuthorizer component like the AgentCore Runtime one's above.

Runtime session isolation and memory segregation

Before we continue the flow and discuss AgentCore Gateway and data access layer, let's pay attention on the security within AgentCore Runtime itself and AgentCore Memory. In AgentCore Runtime, each user session runs in a dedicated microVM with isolated CPU, memory, and filesystem resources. This helps create complete separation between user sessions, safeguarding stateful agent reasoning processes and helps prevent cross-session data contamination. After session completion, the entire microVM is terminated and memory is sanitized.
AgentCore Memory can be used to store both within-session conversation history with short term memory and cross-session extracted information (e.g. user preference) with long term memory. When retrieving short term memory, you use `/memories/memoryId/actor/actorId/sessions/sessionId`. The fact that the memory is access by passing the memoryID, actorID (user ID), and sessionID can act as isolation layer that segregates cross-user data.

MCP tools access control using AgentCore Policy

Now let's continue the flow. Remember the configuration above where your agent in the AgentCore Runtime propagates JWT access token to AgentCore Gateway as header? AgentCore Gateway connects your agent with remote MCP servers or tools implemented in Lambda functions that are made available as MCP tools.
With Policy in  Amazon Bedrock AgentCore , you can specify CEDAR policy  that defines what tools can or cannot be accessed by certain users. The example policy below forbids users with role 'analyst' to call a tool that mutates data, that is to create booking.
1
2
3
4
5
6
7
8
9
forbid(
principal is AgentCore::OAuthUser,
action == AgentCore::Action::"APIInteg___create_booking_tool",
resource == AgentCore::Gateway::"{GATEWAY_ARN}"
)
when {{
principal.hasTag("custom:role") &&
principal.getTag("custom:role") == "analyst"
}};
Not only that the forbid statement will block MCP tools/call for the forbidden tools, but also it will block tools/list requests for those tools as well. This means that the agent being used by users with role 'analyst' in the example above will not even see 'APIInteg___create_booking_tool' exist. This provides defense in depth. Note: While the forbidden tools are hidden, you may also want to avoid the agent system prompt from having direct mentions of those potentially-hidden tools.
To summarize this section, Policy in Amazon Bedrock AgentCore can be used to implement access control for MCP tools based on attributes like the user's role. While by itself, it does not provide tenant isolation in this sample application, it is useful for implementing access control for tools based on user's role.

Gateway to tools

Now with AgentCore Gateway, the code in Lambda functions can be made available as MCP tools. There are other supported targets listed in this documentation .
Back to the JWT flow, AgentCore Gateway invokes these target Lambda functions with IAM Role credentials signed with SigV4. This means that natural JWT flow stops here and that JWT must be passed in another way. The sample application passes the JWT as a header to the target Lambda function by injecting it using Gateway interceptor that extracts and returns the Authorization header. Here is the part of the code in Gateway interceptor that passes the JWT as header.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
# Build response with headers to propagate
response_headers = {}
if auth_header:
response_headers['Authorization'] = auth_header
logger.info("Propagating Authorization header to target")

return {
"interceptorOutputVersion": "1.0",
"mcp": {
"transformedGatewayRequest": {
"body": request_body,
"headers": response_headers
}
}
}
Depending on what your target is, there can be other way that is better in your situation to pass the user identity from AgentCore Gateway to the target. See the matrix here .

Tools to data

With tools implemented in Lambda function, the code inside the Lambda function can decode the JWT and extract the information such as the tenant ID and user's role. The code can also validate the JWT token for defense in depth.
How can these information be used to provide data access level tenant isolation? The answer can be different depending on what database is used and how it is accessed. The sample application uses PostgreSQL on Amazon Aurora that supports RLS. There are prerequisites to be applied:
  1. The database needs to have a regular DB user (not superuser, not the table owner,**** and not BYPASSRLS)) created and the Lambda function must use this user to access the database. If the Lambda function uses superuser, the RLS will be bypassed.
  2. RLS must be enabled first on the relevant tables in the database. See PostgreSQL documentation here . The RLS needs to be configured to allow access to rows where the accound_id field equals to the session variable for the tenant ID. Example is shown below as part of database schema.
1
2
CREATE POLICY rental_admin_bookings ON bookings
FOR ALL USING (get_current_user_role() = 'rental_admin' AND account_id = get_current_account_id());
The function get_current_account_id() fetches the tenant/account ID session variable passed by Lambda function that will be explained below. Also, you may notice that the policy also implements another layer of role-based access control where it allows all operations when the user accessing is an admin. As a comparison, the below policy gives read-only access to users with 'analyst' role. Notice the FOR SELECT below as opposed to FOR ALL in the policy above.
1
2
CREATE POLICY analyst_read_customers ON customers
FOR SELECT USING (get_current_user_role() = 'analyst' AND account_id = get_current_account_id());
With all the prerequisites satisfied, Lambda needs to pass the tenant/account ID information through session variable when setting up the database connection as shown below.
1
2
3
4
5
6
7
8
9
10
11
12
def get_db_connection(rls_context=None):
secret = secrets_client.get_secret_value(SecretId=AURORA_SECRET_ARN)
creds = json.loads(secret['SecretString'])
conn = psycopg2.connect(host=AURORA_ENDPOINT, port=5432, database=AURORA_DATABASE,
user=creds['username'], password=creds['password'])
if rls_context and (rls_context.get('account_id') or rls_context.get('role')):
with conn.cursor() as cur:
if rls_context.get('account_id'):
cur.execute("SET app.current_account_id = %s", [rls_context['account_id']])
if rls_context.get('role'):
cur.execute("SET app.current_user_role = %s", [rls_context['role']])
return conn
With all these set, when Lambda function queries the database, it will pass along the information on which tenant the user belongs to as app.current_account_id as well as which role the user uses as app.current_user_role. The database schema will have a function to fetch this information, for example with current_setting('app.current_account_id', true) as get_current_account_id(). The RLS policies can then use get_current_account_id() function in the condition statement to determine whether to allow access to that particular row (if the value of account_id field matches that).

Anti-pattern

It might be tempting to skip RLS and let the LLM-powered AI agent to simply apply WHERE clause with the account_id filter to limit rows returned based on the propagated authenticated user identity to the agent in the text-to-SQL case. However, this is insecure because LLM can hallucinate, make mistake, or be manipulated with prompt injection that it may expose other tenant's data. Hence, with text-to-SQL, the row filtering has to be done outside of the LLM realm.

Data security in action

Having all these setup, the sample application was able show 2 different answers for the same query for 2 users from different tenants as shown below. The first screenshot was taken when signed in with user lyra.starwhisper@example-mythicalunicorns.com from fictitious tenant 'example-mythicalunicorns.com`
First tenant query result
First tenant query result
The second screenshot below was taken when signed in with user aria.skybloom@example-mythicunicorns.com from fictitious tenant 'example-mythicunicorns.com`
Second tenant query answer
Second tenant query answer
Notice how the same query results in totally different item list that is the result of RLS being in action. Also notice that since it uses text-to-SQL tool, the agent asks for query approval first to improve correctness by implementing human-in-the-loop. The agent is also equipped with chart generation capability using AgentCore CodeInterpreter .
Conclusion
It is possible to implement tenant data isolation when surfacing up data to users through an AI-powered data agent, by propagating the authenticated identity from the front-end all the way to the agent and the tool and data layer. This can be implemented with RLS for databases supporting it. AgentCore Runtime and AgentCore Gateway supports JWT-based authorization that allows the authenticated user's identity be propagated and used to enforce RLS. For access control on agent tools based on roles, Policy in Amazon Bedrock AgentCore with CEDAR can be used with the enforce mode to hide and deny tool access for certain users when using the AI agent. All these are applicable especially for multi-tenant SaaS applications that allows users access to data through AI agents.
Next
Stay tuned for the next blog post(s) on how to implement this with voice-enabled agents with more moving pieces, such as the transport layer and the voice pipeline layer. Also hope I can write another one on implementing multi-tenant isolation for AI agent-enabled applications that are based on semantic layer instead of text-to-SQL. Thank you for reading :)
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article