AWS Bedrock + Claude + MCP Server - Build a Production AI Agent That Never Holds a Database Password (Teleport, RDS PostgreSQL, Terraform)


Disclosure - this post and the video are sponsored by Teleport. The architecture, the code and the opinions are entirely my own - I built and ran everything shown here myself, and the parts that broke are in here too.

👉 Want to build this yourself? Start with Teleport's agentic identity and access control page (the link from the video) - it is the identity layer this whole post is built on, and the free trial is enough to run the demo end to end.

Almost every AI agent demo I see has the same line hidden in a .env file: DB_PASSWORD=.... The agent gets a long-lived, never-expiring database password, often for a super-user, and nobody can say afterwards which query the agent ran. That is fine on a laptop. It is a breach waiting to happen in production, where the "user" typing into your agent might not be you.

So in this post I build it properly. An AI SRE agent runs on Amazon Bedrock with Claude Opus 4.5, reaches a PostgreSQL database on AWS RDS through an MCP server, and investigates a slow-checkout incident by writing its own SQL. The agent never holds a password: Teleport gives it a short-lived certificate, limits it to read-only access on one database, refuses anything else at the proxy, and records every query. All the code is in github.com/rahulwagh/teleport-ai-agent-demo.

TL;DR

  • The agent loop is about 40 lines of Python: Bedrock's Converse API returns stopReason = "tool_use", the agent runs the SQL through the MCP tool, feeds the rows back, and repeats until Claude answers - capped at 12 steps.
  • The MCP server exposes exactly one tool, run_read_query. That single seam is where the security goes; the model itself never touches the database.
  • No password anywhere. tbot (Teleport Machine ID) holds a certificate valid for 1 hour and renews it every 5 minutes; the app connects to 127.0.0.1:5433 with no password, and even Teleport's hop to RDS uses a short-lived IAM auth token.
  • Least privilege is enforced by Teleport, not by string checks: the role allows only db_names: [opsdb] and db_users: [readonly_agent]. Asking to connect as postgres returned access to db denied. User does not have permissions. - before RDS was touched.
  • Every query is in the Teleport audit log under the bot's identity, including the denied attempt.
  • What it cost me: the db.t3.micro RDS instance costs about $0.40-0.50 per day; the optional public UI (ALB + t3.small) is roughly $31-35 per month, so I destroy it after recording.

Table of Content

  1. What we are building
  2. The stack and the request flow
  3. Step 1 - prove you can call Claude on Bedrock
  4. Step 2 - the MCP server, one tool
  5. Step 3 - the agent loop
  6. Step 4 - the static password pitfall
  7. Step 5 - a least-privilege Postgres user with RDS IAM auth
  8. Step 6 - enroll RDS in Teleport and give the agent a role
  9. Step 7 - tbot, the agent's short-lived identity
  10. Live demo - the agent investigates the incident
  11. Escalation denied and the audit trail
  12. Shipping it with Terraform
  13. What broke when I ran this
  14. What it costs
  15. Static password vs Teleport - the comparison
  16. Every Teleport link from the video
  17. Wrap-up

1. What we are building

The incident is planted: a new payment provider, legacy_gateway, was added in the last hour and checkouts through it are crawling. The agent gets one sentence - "Checkout has felt slow in the last hour - investigate" - and has to find that on its own, by querying the orders table.

The complete secure data path - Bedrock, the AI SRE agent, the Teleport identity layer and PostgreSQL, with no stored password

The diagram is the whole idea. Claude on Bedrock only reasons and plans the SQL. The agent connects with no password. Teleport is the only path to the data, and on every request it checks the short-lived identity, enforces least privilege, denies escalation and records the query. PostgreSQL sees the request as readonly_agent.


2. The stack and the request flow

LayerWhat I usedRole in the demo
ModelClaude Opus 4.5 on Amazon Bedrock (global.anthropic.claude-opus-4-5-20251101-v1:0)reasons, decides which SQL to run
AgentPython, boto3 Converse API, MCP client (agent_core.py)the loop: prompt, tool call, result, repeat
ToolMCP server built with FastMCP (mcp_server.py)one tool, run_read_query
DatabaseRDS PostgreSQL 16, db.t3.micro, ap-south-1, IAM auth onthe "production" opsdb
IdentityTeleport Cloud trial, Teleport CLI v18.8.3, Machine ID (tbot)short-lived cert, RBAC, audit
UIStreamlit (app.py)chat, live SQL, cert countdown, "Try admin" button
DeployTerraform: EC2 + ALB + ACM + Route 53optional public HTTPS UI

One question travels like this:

1You type an incident in the chat UI (app.py / Streamlit)
2  -> agent_core.py  -> Amazon Bedrock (Claude)   "run this SQL" (tool_use)
3  -> mcp_server.py  (MCP server, one tool: run_read_query)
4  -> db.py          -> 127.0.0.1:5433  (NO password)
5  -> tbot (Machine ID) -- short-lived cert -->  Teleport Cloud (proxy + RBAC + audit)
6  -> Teleport DB Service -- IAM auth token -->  AWS RDS PostgreSQL
7  <- rows travel back the same path, rendered as a live table in the UI

3. Step 1 - prove you can call Claude on Bedrock

Before any agent, I make one round trip to the model. This is hello_bedrock.py from the repo:

 1import boto3
 2
 3REGION = "ap-south-1"
 4# Newer Claude models on Bedrock require a cross-region *inference profile*,
 5# not the bare model ID.
 6MODEL_ID = "global.anthropic.claude-opus-4-5-20251101-v1:0"
 7
 8client = boto3.client("bedrock-runtime", region_name=REGION)
 9
10def ask(prompt: str) -> str:
11    resp = client.converse(
12        modelId=MODEL_ID,
13        messages=[{"role": "user", "content": [{"text": prompt}]}],
14        inferenceConfig={"maxTokens": 200, "temperature": 0.2},
15    )
16    return resp["output"]["message"]["content"][0]["text"]

Two things here cost me time, and both are in section 13: the global. prefix (an inference profile, not a model ID) and the one-time model access form for Anthropic models, described on AWS's model access page.


4. Step 2 - the MCP server, one tool

The database tool lives in its own process and speaks the Model Context Protocol over stdio. FastMCP turns the type hints and the docstring into the tool schema, so I do not hand-write any JSON:

 1from mcp.server.fastmcp import FastMCP
 2from db import run_read_query as _db_query
 3
 4mcp = FastMCP("orders-db")
 5
 6@mcp.tool()
 7def run_read_query(sql: str) -> str:
 8    """Run a read-only SQL SELECT against the production 'opsdb' Postgres
 9    database and return matching rows as JSON.
10
11    Table 'orders' columns: order_id, customer_email, status
12    (completed|pending_payment|failed), amount_usd, payment_provider
13    (stripe|paypal|legacy_gateway), checkout_duration_ms, created_at (timestamptz).
14    """
15    rows = _db_query(sql)
16    return json.dumps(rows, default=str)
17
18if __name__ == "__main__":
19    mcp.run()

The docstring matters more than it looks: it is all Claude knows about the table. Listing the allowed values of status and payment_provider is what lets the model write a correct GROUP BY payment_provider on its first try.

Why an MCP server and not a plain function? Because this boundary - agent on one side, the only thing that touches data on the other - is exactly the seam you secure. The agent discovers the tool with list_tools() at start-up, so I can add or swap tools without touching the agent.


5. Step 3 - the agent loop

This is the heart of agent_core.py, trimmed to the loop:

 1for _ in range(max_steps):                       # max_steps = 12
 2    resp = bedrock.converse(
 3        modelId=MODEL_ID,
 4        system=SYSTEM,
 5        messages=messages,
 6        toolConfig={"tools": tools},             # MCP tools translated to toolSpec
 7        inferenceConfig={"maxTokens": 1500, "temperature": 0},
 8    )
 9    reply = resp["output"]["message"]
10    messages.append(reply)
11
12    if resp["stopReason"] != "tool_use":         # Claude is done - final diagnosis
13        return
14
15    tool_results = []
16    for block in reply["content"]:
17        if "toolUse" not in block:
18            continue
19        tool_use = block["toolUse"]
20        result = await session.call_tool(tool_use["name"], tool_use["input"])
21        text = "".join(b.text for b in result.content if hasattr(b, "text"))
22        tool_results.append({"toolResult": {
23            "toolUseId": tool_use["toolUseId"],
24            "content": [{"text": text}],
25            "status": "error" if result.isError else "success",
26        }})
27    messages.append({"role": "user", "content": tool_results})

The system prompt tells Claude to form a hypothesis, query to confirm it, iterate, and finish with a root-cause diagnosis "with the numbers that support it". temperature: 0 keeps the investigation repeatable, and the max_steps cap of 12 stops a confused model from looping forever. Passing the MCP tool's JSON schema straight into Bedrock's toolConfig is a three-line translation (to_bedrock_tools() in the repo).


6. Step 4 - the static password pitfall

db.py has two modes, and I deliberately kept the wrong one for the before/after:

1# The wrong way: static master password straight to a public RDS endpoint.
2return psycopg2.connect(
3    host=os.environ["DB_HOST"],
4    port=os.environ.get("DB_PORT", "5432"),
5    dbname=os.environ["DB_NAME"],
6    user=os.environ["DB_USER"],
7    password=os.environ["DB_PASSWORD"],
8    connect_timeout=10,
9)

Everything about this is a problem once an AI is in the loop: the password never expires, it usually belongs to a powerful user, the RDS endpoint has to be reachable, and the database log shows the same user for the agent, your migrations and you. My only guardrail in this mode is a string check that the query starts with select - and string checks are easy to fool.

The Teleport mode is the same function with almost nothing in it:

1# tbot's database-tunnel listens here and owns the (short-lived) identity.
2return psycopg2.connect(
3    host="127.0.0.1",
4    port=os.environ.get("TELEPORT_DB_PORT", "5433"),
5    dbname=os.environ.get("DB_NAME", "opsdb"),
6    user=os.environ.get("TELEPORT_DB_USER", "readonly_agent"),
7    sslmode="disable",   # local hop to tbot; tbot does mTLS upstream
8    connect_timeout=30,  # first connect warms the tunnel/cert (cold start)
9)

No password, no TLS code, no authorization logic in the application. That is the point.


7. Step 5 - a least-privilege Postgres user with RDS IAM auth

On the database side, the agent gets its own user that can do almost nothing. admin_setup.py runs once as the master user. This is the real output from the recording:

 1$ .venv/bin/python admin_setup.py
 2OK: DO $$ BEGIN IF NOT EXISTS (SELECT FROM pg_roles WHERE rolname = 'reado
 3OK: GRANT rds_iam TO readonly_agent;
 4OK: GRANT CONNECT ON DATABASE opsdb TO readonly_agent;
 5OK: GRANT USAGE ON SCHEMA public TO readonly_agent;
 6OK: GRANT SELECT ON public.orders TO readonly_agent;
 7OK: REVOKE CREATE ON SCHEMA public FROM readonly_agent;
 8
 9Grants for readonly_agent:
10   ('readonly_agent', 'SELECT', 'orders')
11rds_iam membership: yes

readonly_agent has no password at all. Membership in rds_iam means RDS accepts a short-lived IAM token for it instead - IAM database authentication has to be switched on for the instance. Its only privilege is SELECT on one table.


8. Step 6 - enroll RDS in Teleport and give the agent a role

I did the enrollment on camera in the Teleport web UI: Connect AWS Account, Enroll RDS Database, Deploy Database Service, Set Up Access, Test Connection. The wizard deployed the Teleport Database Service as a task on Amazon ECS in my account, and that service is the only thing that talks to RDS - using IAM tokens, so no password exists in the Teleport path either. The steps are in Teleport's database access guides.

Then the policy for the agent, teleport/role-agent-db-readonly.yaml:

 1kind: role
 2version: v7
 3metadata:
 4  name: agent-db-readonly
 5spec:
 6  allow:
 7    db_labels:
 8      '*': ['*']             # match any DB resource - the real lock-down is the two lines below
 9    db_names: ["opsdb"]      # only this database
10    db_users: ["readonly_agent"]   # only the read-only Postgres user
11  deny: {}
12  options:
13    max_session_ttl: 1h      # keep the agent's issued certs short-lived

Two layers of least privilege now stack up: Teleport only lets the agent log in as readonly_agent on opsdb, and Postgres only lets readonly_agent read orders.


9. Step 7 - tbot, the agent's short-lived identity

Machine ID gives a non-human workload its own identity. tbot authenticates as the bot agent-bot, keeps a short-lived certificate on disk, and opens local database tunnels. This is the config template from the repo (teleport/tbot.yaml.tmpl, rendered by setup.sh):

 1version: v2
 2proxy_server: __TENANT__:443
 3
 4onboarding:
 5  join_method: token
 6  token: __BOT_TOKEN__
 7
 8storage:
 9  type: directory
10  path: ./teleport/tbot-data    # short-lived identity lives here; gitignored
11
12renewal_interval: 5m
13
14services:
15  - type: database-tunnel
16    listen: tcp://127.0.0.1:5433
17    service: teleport-demo-db
18    database: opsdb
19    username: readonly_agent
20
21  # DENIAL DEMO: same agent identity asking to be the postgres SUPER-USER.
22  - type: database-tunnel
23    listen: tcp://127.0.0.1:5434
24    service: teleport-demo-db
25    database: opsdb
26    username: postgres

The second tunnel exists only to prove the point: same identity, but asking for postgres. The role does not allow it, so Teleport should refuse it - section 11 shows that it does.

With tbot running, the UI's Teleport panel shows the identity and the certificate counting down:

AI SRE agent UI - identity agent-bot as readonly_agent, certificate expires in 3579 seconds, renewed 18 seconds ago

Cert expires in 3579s, Renewed 18s ago - a certificate valid for just under the 1-hour max_session_ttl, refreshed every 5 minutes. If the agent's machine is compromised, the stolen credential is useless within the hour, and removing the bot stops the renewals.


10. Live demo - the agent investigates the incident

The seed data has 40 healthy orders spread over the last 40 hours (stripe and paypal, 150-550 ms checkouts) and 18 orders from the last hour through legacy_gateway that take 8,000-14,000 ms, many stuck in pending_payment. I asked the agent to show me the orders that take a lot of time. It connected to the MCP server, wrote its own SQL, and Teleport carried every query:

The agent's own SQL results through Teleport - the slowest checkouts all use legacy_gateway

The slowest rows it pulled back, as they appeared on screen:

payment_providercheckout_duration_mscreated_at (UTC)
legacy_gateway138172026-06-14 19:22:28
legacy_gateway128432026-06-14 19:01:28
legacy_gateway126002026-06-14 19:13:28
legacy_gateway120682026-06-14 18:58:28
legacy_gateway116752026-06-14 18:55:28
legacy_gateway114942026-06-14 19:25:28

Every one of the slowest checkouts is legacy_gateway, all within the same hour - which is exactly the planted root cause, found by a model that was only told "checkout feels slow". Claude then ran aggregate queries (minimum, average, median and 95th percentile of checkout_duration_ms) to put numbers on it, as the system prompt asks.


11. Escalation denied and the audit trail

The UI has a Try an admin action button that connects through the :5434 tunnel - the same agent-bot identity, asking to be postgres:

DENIED by Teleport - the agent identity asking for the postgres super-user is refused at the proxy

1DENIED by Teleport
2connection to server at "127.0.0.1", port 5434 failed: access to db denied.
3User does not have permissions. Confirm database user and name.

The refusal comes from the Teleport proxy, before the connection reaches RDS. No Postgres permission was even consulted.

Then the audit log in the Teleport web UI. Both the allowed and the denied sessions are there, under the bot's identity:

Teleport audit log - certificates issued to bot-agent-bot, the denied postgres session and the recorded session

1Certificate Issued       User certificate issued for [bot-agent-bot]
2User Updated             User [bot-agent-bot] has been updated
3Session Uploaded         Recorded session [249597f3-a4a5-47e3-93d9-e63ddc69c51c] has been uploaded
4Database Session Denied  User [bot-agent-bot] was denied access to database [opsdb] as [postgres] on [teleport-demo-db]
5Database Session Ended   User [bot-agent-bot] has disconnected from database [opsdb] on [teleport-demo-db]

The repeating Certificate Issued / User Updated pairs are the 5-minute renewals - a visible heartbeat of the short-lived identity. If someone asks "what did the AI do to production last Tuesday?", this is the answer, tied to one machine identity rather than a shared password.


12. Shipping it with Terraform

For a public demo, the terraform/ folder in the repo puts the Streamlit UI on EC2 behind an ALB with an ACM certificate:

1Internet -HTTPS-> ALB (ACM) -HTTP:8501-> EC2 (Streamlit + tbot) -5433-> tbot -443-> Teleport Cloud -> RDS

The instance joins Teleport with IAM join (no secret token on the box), runs tbot and Streamlit as systemd services, and needs only outbound 443 to Teleport - it still holds no database password. The deploy is two-phase because ACM DNS validation needs the domain delegated to the new Route 53 zone first:

1cp terraform.tfvars.example terraform.tfvars   # set domain_name + subdomain
2terraform init
3terraform apply -target=aws_route53_zone.primary
4terraform output name_servers                  # put these NS records at your registrar
5terraform apply
6terraform output url

The instance needs about 3-4 minutes after boot to install everything. If .tfvars and -target are new to you, variables.tf and terraform.tfvars explains how the example file is loaded, and the network side (public subnets for the ALB, the instance's security group) follows the same pattern as my VPC with public and private subnets post.


13. What broke when I ran this

I built this over several days in June 2026 in ap-south-1, and I kept notes of everything that went wrong. These are the ones worth knowing before you start.

1. Claude rejected the bare model ID. Current Claude models on Bedrock will not run on demand by plain model ID; you must call an inference profile - here global.anthropic.claude-opus-4-5-20251101-v1:0. hello_bedrock.py still carries the hint I wrote for myself: on a ValidationException mentioning on-demand, switch to a profile ID.

2. The model I wanted was gated as "Legacy". Claude Sonnet 4 was blocked because the account had not used it in the last 30 days, so it counted as a legacy model for that account. Opus 4.5, a current model with an active profile in the region, worked.

3. The Anthropic use-case form takes a while. The first time you enable Anthropic models in an account, Bedrock asks for use-case details. After I submitted it, it took about 10-15 minutes before calls stopped failing.

4. My database "went down" every morning. It had not - my home IP had changed overnight, and the RDS security group only allowed port 5432 from my old IP, so every connection timed out. Re-authorizing the new IP fixed it. With the Teleport path this whole class of problem disappears, because only the Teleport Database Service needs to reach RDS.

5. tbot could not find its config. On camera, tbot start failed twice because I ran it from the wrong folder - first from inside teleport/, then from the folder above the project - with a path that is relative to the project folder:

1rahulwagh@Rahuls-MacBook-Pro-2 teleport % tbot start -c ./teleport/tbot.yaml
2ERROR: loading bot config from path ./teleport/tbot.yaml
3        failed to open file: ./teleport/tbot.yaml
4                lstat /Users/rahulwagh/Documents/... (path shortened)
5teleport: no such file or directory

Run it from the demo-ai-agent folder, or pass an absolute path to -c.

6. The UI showed a certificate that had expired 197,269 seconds ago. Before tbot was running, the panel read Cert expires in -197269s - about 55 hours in the past, left over in tbot-data/ from an earlier session - and the agent's first query failed with:

Teleport tunnel isn't reachable on :5433 - and a certificate that expired 197269 seconds ago because tbot was not running

1Teleport tunnel isn't reachable on :5433. Start it with tbot start -c ./teleport/tbot.yaml,
2or flip off 'Route through Teleport identity'.

This is the security model working: an old identity file on disk is worthless. Starting tbot minted a fresh certificate in seconds.


14. What it costs

ItemCostNotes
RDS PostgreSQL db.t3.microabout $0.40-0.50 per daymy estimate in the build notes; delete it after recording
ALB for the public UIabout $16-20 per monthonly if you deploy the Terraform UI
EC2 t3.small for the UIabout $15 per monthsame
Teleport Cloud14-day trialwhat I used for the whole build
Bedrock (Claude Opus 4.5)per input/output tokensee Amazon Bedrock pricing; an investigation is a handful of short calls

The RDS and ALB numbers are why my repo notes end with a teardown section. Check current prices on the RDS for PostgreSQL pricing page for your region.


15. Static password vs Teleport - the comparison

Static password in .envTeleport Machine ID
Credential the app holdslong-lived DB passwordnone - tbot holds a short-lived cert
Lifetimeuntil someone rotates it1 hour max, renewed every 5 minutes
Who the database seesa shared user (often the master user)readonly_agent, through agent-bot
Escalation to super-userpossible if the password allows itdenied at the proxy before RDS
RDS exposureendpoint reachable from the apponly the Teleport Database Service connects
AuditPostgres logs, if enabled, under a shared userevery session and denial under the bot identity
App code for authpassword handling, TLS settingsnone

  1. Teleport sign-up - agentic identity and access control - the link from the full video's description
  2. Teleport sign-up from the YouTube Short - same page, from the 91-second short version of this video
  3. The GitHub repository - the repo link from the description (github.com/rahulwagh/teleport-ai-agent-demo)
  4. Teleport documentation - the starting point I used for tsh, tctl and tbot

If you are building agents that touch infrastructure, my durable Kubernetes incident agent with human approval is the companion pattern for write actions, and assuming an IAM role in AWS covers the AWS side of short-lived credentials.


17. Wrap-up

The model was never the risky part of this agent - Claude on Bedrock only ever produced SQL text. The risky part is the credential that turns that text into a query on production. To summarise what I built:

  1. Bedrock Converse plus an MCP client gives you a tool-using agent in about 40 lines; keep the tool surface to one well-described tool.
  2. Put the security at the MCP-to-database seam, not in the prompt and not in string checks.
  3. Give the agent its own identity with Teleport Machine ID: a 1-hour certificate renewed every 5 minutes, no password in the app.
  4. Enforce least privilege twice - Teleport limits who the agent can log in as, Postgres limits what that user can do - and verify it with a denied super-user attempt.
  5. Use the audit log as the answer to "what did the AI do?".
👉 Try it with Teleport - start here, then clone the repo; the README takes you from an empty AWS account to the agent in this post.

Did this save you a debugging session? Stay in the loop -

Posts in this series