Skip to content

Making Conversations Private (Part ⅔)

This is part 2 of our authentication series:

  1. Basic Authentication - Control who can access your bot
  2. Resource Authorization (you are here) - Let users have private conversations
  3. Production Auth - Add real user accounts and validate using OAuth2

In this tutorial, we will extend our chatbot to give each user their own private conversations. We'll add resource-level access control so users can only see their own threads.

Authorization handlers

Understanding Resource Authorization

In the last tutorial, we controlled who could access our bot. But right now, any authenticated user can see everyone else's conversations! Let's fix that by adding resource authorization.

First, make sure you have completed the Basic Authentication tutorial and that your secure bot can be run without errors:

cd custom-auth
pip install -e .
langgraph dev --no-browser

Adding Resource Authorization

Recall that in the last tutorial, the Auth object let us register an authentication function, which the LangGraph platform uses to validate the bearer tokens in incoming requests. Now we'll use it to register an authorization handler.

Authorization handlers are functions that run after authentication succeeds. These handlers can add metadata to resources (like who owns them) and filter what each user can see.

Let's update our src/security/auth.py and add one authorization handler that is run on every request:

src/security/auth.py
from langgraph_sdk import Auth

# Keep our test users from the previous tutorial
VALID_TOKENS = {
    "user1-token": {"id": "user1", "name": "Alice"},
    "user2-token": {"id": "user2", "name": "Bob"},
}

auth = Auth()


@auth.authenticate
async def get_current_user(authorization: str | None) -> Auth.types.MinimalUserDict:
    """Our authentication handler from the previous tutorial."""
    assert authorization
    scheme, token = authorization.split()
    assert scheme.lower() == "bearer"

    if token not in VALID_TOKENS:
        raise Auth.exceptions.HTTPException(status_code=401, detail="Invalid token")

    user_data = VALID_TOKENS[token]
    return {
        "identity": user_data["id"],
    }


@auth.on
async def add_owner(
    ctx: Auth.types.AuthContext,  # Contains info about the current user
    value: dict,  # The resource being created/accessed
):
    """Make resources private to their creator."""
    # Add owner when creating resources
    filters = {"owner": ctx.user.identity}
    metadata = value.setdefault("metadata", {})
    metadata.update(filters)

    # Only let users see their own resources
    return filters

The handler receives two parameters:

  1. ctx (AuthContext): contains info about the current user, the user's permissions, the resource ("threads", "crons", "assistants"), and the action being taken ("create", "read", "update", "delete", "search", "create_run")
  2. value (dict): data that is being created or accessed. The contents of this dict depend on the resource and action being accessed. See adding scoped authorization handlers below for information on how to get more tightly scoped access control.

Notice that our simple handler does two things:

  1. Adds the user's ID to the resource's metadata.
  2. Returns a metadata filter so users only see resources they own.

Testing Private Conversations

Let's test our authorization. If we have set things up correctly, we should expect to see all ✅ messages. Be sure to have your development server running (run langgraph dev):

from langgraph_sdk import get_client

# Create clients for both users
alice = get_client(
    url="http://localhost:2024",
    headers={"Authorization": "Bearer user1-token"}
)

bob = get_client(
    url="http://localhost:2024",
    headers={"Authorization": "Bearer user2-token"}
)

# Alice creates an assistant
alice_assistant = await alice.assistants.create()
print(f"✅ Alice created assistant: {alice_assistant['assistant_id']}")

# Alice creates a thread and chats
alice_thread = await alice.threads.create()
print(f"✅ Alice created thread: {alice_thread['thread_id']}")

await alice.runs.create(
    thread_id=alice_thread["thread_id"],
    assistant_id="agent",
    input={"messages": [{"role": "user", "content": "Hi, this is Alice's private chat"}]}
)

# Bob tries to access Alice's thread
try:
    await bob.threads.get(alice_thread["thread_id"])
    print("❌ Bob shouldn't see Alice's thread!")
except Exception as e:
    print("✅ Bob correctly denied access:", e)

# Bob creates his own thread
bob_thread = await bob.threads.create()
await bob.runs.create(
    thread_id=bob_thread["thread_id"],
    assistant_id="agent",
    input={"messages": [{"role": "user", "content": "Hi, this is Bob's private chat"}]}
)
print(f"✅ Bob created his own thread: {bob_thread['thread_id']}")

# List threads - each user only sees their own
alice_threads = await alice.threads.search()
bob_threads = await bob.threads.search()
print(f"✅ Alice sees {len(alice_threads)} thread")
print(f"✅ Bob sees {len(bob_threads)} thread")

Run the test code and you should see output like this:

 Alice created assistant: fc50fb08-78da-45a9-93cc-1d3928a3fc37
 Alice created thread: 533179b7-05bc-4d48-b47a-a83cbdb5781d
 Bob correctly denied access: Client error '404 Not Found' for url 'http://localhost:2024/threads/533179b7-05bc-4d48-b47a-a83cbdb5781d'
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
 Bob created his own thread: 437c36ed-dd45-4a1e-b484-28ba6eca8819
 Alice sees 1 thread
 Bob sees 1 thread

This means:

  1. Each user can create and chat in their own threads
  2. Users can't see each other's threads
  3. Listing threads only shows your own

Adding scoped authorization handlers

The broad @auth.on handler matches on all authorization events. This is concise, but it means the contents of the value dict are not well-scoped, and we apply the same user-level access control to every resource. If we want to be more fine-grained, we can also control specific actions on resources.

Update src/security/auth.py to add handlers for specific resource types:

# Keep our previous handlers...

from langgraph_sdk import Auth

@auth.on.threads.create
async def on_thread_create(
    ctx: Auth.types.AuthContext,
    value: Auth.types.on.threads.create.value,
):
    """Add owner when creating threads.

    This handler runs when creating new threads and does two things:
    1. Sets metadata on the thread being created to track ownership
    2. Returns a filter that ensures only the creator can access it
    """
    # Add owner metadata to the thread being created
    # This metadata is stored with the thread and persists
    metadata = value.setdefault("metadata", {})
    metadata["owner"] = ctx.user.identity

    # Return filter to restrict access to just the creator
    return {"owner": ctx.user.identity}

@auth.on.threads.read
async def on_thread_read(
    ctx: Auth.types.AuthContext,
    value: Auth.types.on.threads.read.value,
):
    """Only let users read their own threads.

    This handler runs on read operations. We don't need to set
    metadata since the thread already exists - we just need to
    return a filter to ensure users can only see their own threads.
    """
    return {"owner": ctx.user.identity}

@auth.on.assistants
async def on_assistants(
    ctx: Auth.types.AuthContext,
    value: Auth.types.on.assistants.value,
):
    # For illustration purposes, we will deny all requests
    # that touch the assistants resource
    raise Auth.exceptions.HTTPException(
        status_code=403,
        detail="User lacks the required permissions.",
    )

Notice that instead of one global handler, we now have specific handlers for:

  1. Creating threads
  2. Reading threads
  3. Accessing assistants

The first three of these match specific actions on each resource (see resource actions), while the last one (@auth.on.assistants) matches any action on the assistants resource. For each request, LangGraph will run the most specific handler that matches the resource and action being accessed. This means that the four handlers above will run rather than the broadly scoped "@auth.on" handler.

Try adding the following test code to your test file:

# ... Same as before
# Try creating an assistant. This should fail
try:
    await alice.assistants.create("agent")
    print("❌ Alice shouldn't be able to create assistants!")
except Exception as e:
    print("✅ Alice correctly denied access:", e)

# Try searching for assistants. This also should fail
try:
    await alice.assistants.search()
    print("❌ Alice shouldn't be able to search assistants!")
except Exception as e:
    print("✅ Alice correctly denied access to searching assistants:", e)

# Alice can still create threads
alice_thread = await alice.threads.create()
print(f"✅ Alice created thread: {alice_thread['thread_id']}")

And then run the test code again:

 Alice created thread: dcea5cd8-eb70-4a01-a4b6-643b14e8f754
 Bob correctly denied access: Client error '404 Not Found' for url 'http://localhost:2024/threads/dcea5cd8-eb70-4a01-a4b6-643b14e8f754'
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/404
 Bob created his own thread: 400f8d41-e946-429f-8f93-4fe395bc3eed
 Alice sees 1 thread
 Bob sees 1 thread
 Alice correctly denied access:
For more information check: https://developer.mozilla.org/en-US/docs/Web/HTTP/Status/500
 Alice correctly denied access to searching assistants:

Congratulations! You've built a chatbot where each user has their own private conversations. While this system uses simple token-based authentication, the authorization patterns we've learned will work with implementing any real authentication system. In the next tutorial, we'll replace our test users with real user accounts using OAuth2.

What's Next?

Now that you can control access to resources, you might want to:

  1. Move on to Production Auth to add real user accounts
  2. Read more about authorization patterns
  3. Check out the API reference for details about the interfaces and methods used in this tutorial

Comments