Most LLM applications break when more than one person uses them — not because the AI fails, but because the security layer was never built. This lesson shows you how to implement hashed API key management, isolated per-user session stores, and role-based access control from scratch in Python.

Imagine you've built a beautifully functional AI assistant — it answers questions intelligently, remembers context from earlier in the conversation, and integrates with your company's knowledge base. You deploy it, invite your first ten users, and then the problems start. User A can see User B's conversation history. An intern accidentally triggers an expensive batch-processing feature meant only for senior analysts. A disgruntled contractor who left last month is still querying your system, burning through your API budget. None of these are AI problems. They're authentication and authorization problems — and they'll sink your application faster than a poorly-written prompt ever could.
This lesson closes that gap. We'll build a complete security layer for a multi-user LLM application from the ground up. You'll learn how to issue and validate API keys, isolate each user's session so their data never bleeds into someone else's context, and implement a role-based access control (RBAC) system that gives different users different capabilities. Every concept comes with working Python code you can drop into a real project.
By the end, you'll have the skills to run a secure, multi-tenant LLM service — one where your users trust you with their data, and your infrastructure trusts no one unconditionally.
What you'll learn:
You should be comfortable writing Python functions, working with dictionaries and classes, and have a basic understanding of how HTTP requests work. You don't need prior security experience — we'll build everything from first principles. If you've worked through Using the OpenAI API with Python or the Building Your First AI App with the Claude API guide, you're in exactly the right place.
Install the dependencies we'll use:
pip install fastapi uvicorn python-jose[cryptography] passlib[bcrypt] redis openai python-dotenv
Before writing a single line of code, let's get the vocabulary right, because these two terms are constantly conflated — and confusing them leads to real security holes.
Authentication answers the question: Who are you? When you log into your bank, the login form authenticates you. It establishes your identity. In an API context, this usually means presenting a credential — a password, a token, or an API key — and having the system verify it's valid.
Authorization answers the question: What are you allowed to do? After you're logged in, the bank's software decides that you can view your own account balance but not access someone else's. Authorization is about permissions, not identity.
In an LLM application, this distinction matters more than you might think:
Think of a hotel: authentication is checking your ID at the front desk and getting a key card. Authorization is what that key card opens — your room, the gym, maybe the executive lounge. The systems are separate and work together.
Key insight
Authentication without authorization means anyone who gets in can do anything. Authorization without authentication means you can't actually verify who you're making decisions about. You need both.
Most LLM applications are consumed programmatically — a Python script, a web frontend, a mobile app. These clients don't "log in" interactively. They identify themselves on every request with a long-lived credential. That's an API key: a secret string that acts as both identity and proof of that identity.
The naive approach is to generate a random string, store it in a database, and compare it on each request. The problem? If your database is ever compromised, every API key in it is immediately usable by an attacker. The correct approach mirrors how passwords are stored: hash the key before saving it, and only compare hashes.
Here's the flow:
hashlib.sha256 for speed — bcrypt is for passwords, not tokens)import hashlib
import secrets
import sqlite3
from datetime import datetime
from typing import Optional
def generate_api_key() -> tuple[str, str]:
"""
Generate a new API key and its hash.
Returns (plaintext_key, hashed_key).
The plaintext key is shown to the user once and never stored.
"""
plaintext = "sk-" + secrets.token_urlsafe(32)
hashed = hashlib.sha256(plaintext.encode()).hexdigest()
return plaintext, hashed
def store_api_key(
db: sqlite3.Connection,
user_id: str,
key_name: str,
hashed_key: str,
role: str = "user"
) -> None:
"""
Store a hashed API key in the database.
We never store the plaintext.
"""
db.execute("""
INSERT INTO api_keys (user_id, key_name, hashed_key, role, created_at, is_active)
VALUES (?, ?, ?, ?, ?, 1)
""", (user_id, key_name, hashed_key, role, datetime.utcnow().isoformat()))
db.commit()
def validate_api_key(
db: sqlite3.Connection,
incoming_key: str
) -> Optional[dict]:
"""
Validate an incoming API key.
Returns the user record if valid, None otherwise.
"""
hashed = hashlib.sha256(incoming_key.encode()).hexdigest()
cursor = db.execute("""
SELECT user_id, role, key_name
FROM api_keys
WHERE hashed_key = ? AND is_active = 1
""", (hashed,))
row = cursor.fetchone()
if row:
return {"user_id": row[0], "role": row[1], "key_name": row[2]}
return None
Now let's set up the database schema:
def init_db(db_path: str = "app.db") -> sqlite3.Connection:
conn = sqlite3.connect(db_path, check_same_thread=False)
conn.execute("""
CREATE TABLE IF NOT EXISTS api_keys (
id INTEGER PRIMARY KEY AUTOINCREMENT,
user_id TEXT NOT NULL,
key_name TEXT NOT NULL,
hashed_key TEXT UNIQUE NOT NULL,
role TEXT NOT NULL DEFAULT 'user',
created_at TEXT NOT NULL,
last_used_at TEXT,
is_active INTEGER NOT NULL DEFAULT 1
)
""")
conn.commit()
return conn
Let's watch this work end-to-end:
db = init_db()
# Alice signs up — we generate a key, show it to her once
plaintext_key, hashed_key = generate_api_key()
store_api_key(db, user_id="alice@company.com", key_name="production-key", hashed_key=hashed_key, role="analyst")
print(f"Your API key (save this, it won't be shown again): {plaintext_key}")
# Output: Your API key (save this, it won't be shown again): sk-9Kx2mP...
# Later, Alice makes a request — we validate it
user_record = validate_api_key(db, plaintext_key)
print(user_record)
# Output: {'user_id': 'alice@company.com', 'role': 'analyst', 'key_name': 'production-key'}
# Attempt with a fake key fails cleanly
print(validate_api_key(db, "sk-fakekeythatdoesntexist"))
# Output: None
Warning
Never log incoming API keys, even in debug mode. If a key appears in a log file and that file is shipped to a centralized logging service, you've effectively published the key. Log the key's hash or the user ID instead.
LLM applications are stateful in a way traditional CRUD applications aren't. Each user's conversation builds a growing history that gets sent back to the model on every turn. If you naively store all conversation history in a single shared list or a single Redis key, you will eventually mix up users' context — and when that happens, User A might receive fragments of User B's private conversation in their AI response.
This isn't theoretical. It happens when developers use simple global state or forget to scope their database keys per user.
The solution is session isolation: every piece of state belonging to a user must be stored and retrieved using a key that includes that user's identifier. No user can ever read or write to another user's namespace.
Note
Session isolation is also the foundation for the kind of multi-tenant architecture described in Building a Multi-Tenant LLM Platform: Isolating Contexts, Enforcing Usage Quotas, and Managing Costs Per Customer. Understanding isolation at this level makes the more advanced patterns there immediately accessible.
We'll use Redis as our session store because it's fast, supports key expiration (critical for security), and is standard in production deployments. The key pattern is: session:{user_id}:{session_id}.
import redis
import json
import uuid
from typing import Optional
class SessionStore:
"""
Manages isolated conversation sessions per user.
Each session is stored under a namespaced key so users
can never access each other's history.
"""
def __init__(self, redis_url: str = "redis://localhost:6379", ttl_seconds: int = 3600):
self.redis = redis.from_url(redis_url, decode_responses=True)
self.ttl = ttl_seconds # Sessions expire after 1 hour of inactivity
def _make_key(self, user_id: str, session_id: str) -> str:
"""
Build the namespaced Redis key.
This is the critical isolation boundary.
user_id is always part of the key, so a session_id alone
is never sufficient to retrieve data.
"""
return f"session:{user_id}:{session_id}"
def create_session(self, user_id: str) -> str:
"""Start a new conversation session for a user."""
session_id = str(uuid.uuid4())
key = self._make_key(user_id, session_id)
self.redis.setex(key, self.ttl, json.dumps([]))
return session_id
def append_message(
self,
user_id: str,
session_id: str,
role: str,
content: str
) -> bool:
"""
Add a message to a user's session history.
Returns False if the session doesn't exist or doesn't
belong to this user — both conditions look identical
to the caller (no information leakage).
"""
key = self._make_key(user_id, session_id)
raw = self.redis.get(key)
if raw is None:
return False
history = json.loads(raw)
history.append({"role": role, "content": content})
# Reset TTL on each write — sessions expire from last activity
self.redis.setex(key, self.ttl, json.dumps(history))
return True
def get_history(self, user_id: str, session_id: str) -> Optional[list]:
"""
Retrieve conversation history for a specific user's session.
Returns None if not found (session never existed, expired, or
belongs to a different user — caller can't distinguish which).
"""
key = self._make_key(user_id, session_id)
raw = self.redis.get(key)
if raw is None:
return None
return json.loads(raw)
def delete_session(self, user_id: str, session_id: str) -> None:
"""Let users delete their own session data."""
key = self._make_key(user_id, session_id)
self.redis.delete(key)
Notice the security-conscious design in get_history: it returns None whether the session doesn't exist, has expired, or belongs to another user. An attacker who guesses a session ID from a different user gets the same "not found" response as if the session never existed. This is called security through ambiguity of error messages — don't give attackers information that helps them distinguish between "wrong user" and "wrong session ID."
For a deeper look at managing the conversation histories that go into these sessions, the guide on Implementing Conversational Memory: Managing Context Windows and Chat History at Scale is the natural next read.
RBAC is the practice of assigning users to roles (like "admin," "analyst," or "viewer") and then defining what each role can do. Instead of checking "can Alice access feature X?" you check "can users with the analyst role access feature X?" This scales beautifully — when you add a new feature, you just update the role definition, not every individual user record.
Let's design a realistic permission model for an internal AI analytics tool:
| Role | What They Can Do |
|---|---|
viewer |
Ask questions, view their own history |
analyst |
Everything viewer can do + run batch analysis jobs |
admin |
Everything analyst can do + manage users, view all sessions, adjust rate limits |
from enum import Enum
from typing import Set
from functools import wraps
class Permission(str, Enum):
# Conversation permissions
CHAT = "chat"
VIEW_OWN_HISTORY = "view_own_history"
DELETE_OWN_HISTORY = "delete_own_history"
# Advanced feature permissions
BATCH_ANALYSIS = "batch_analysis"
DOCUMENT_UPLOAD = "document_upload"
# Administrative permissions
VIEW_ALL_SESSIONS = "view_all_sessions"
MANAGE_USERS = "manage_users"
ADJUST_RATE_LIMITS = "adjust_rate_limits"
ROLE_PERMISSIONS: dict[str, Set[Permission]] = {
"viewer": {
Permission.CHAT,
Permission.VIEW_OWN_HISTORY,
Permission.DELETE_OWN_HISTORY,
},
"analyst": {
Permission.CHAT,
Permission.VIEW_OWN_HISTORY,
Permission.DELETE_OWN_HISTORY,
Permission.BATCH_ANALYSIS,
Permission.DOCUMENT_UPLOAD,
},
"admin": {
Permission.CHAT,
Permission.VIEW_OWN_HISTORY,
Permission.DELETE_OWN_HISTORY,
Permission.BATCH_ANALYSIS,
Permission.DOCUMENT_UPLOAD,
Permission.VIEW_ALL_SESSIONS,
Permission.MANAGE_USERS,
Permission.ADJUST_RATE_LIMITS,
},
}
def has_permission(role: str, permission: Permission) -> bool:
"""Check whether a role includes a given permission."""
allowed = ROLE_PERMISSIONS.get(role, set())
return permission in allowed
Now let's wire authentication, session isolation, and RBAC together inside a FastAPI application. FastAPI's dependency injection system is perfect for this — we define the security logic once as a dependency and apply it to any route.
from fastapi import FastAPI, HTTPException, Depends, Header
from pydantic import BaseModel
import openai
import os
app = FastAPI()
db = init_db()
sessions = SessionStore()
openai_client = openai.OpenAI(api_key=os.getenv("OPENAI_API_KEY"))
class AuthenticatedUser(BaseModel):
user_id: str
role: str
def get_current_user(x_api_key: str = Header(...)) -> AuthenticatedUser:
"""
FastAPI dependency: validates the API key from the X-Api-Key header
and returns a structured user object.
Raises 401 if the key is invalid.
"""
user_record = validate_api_key(db, x_api_key)
if not user_record:
raise HTTPException(status_code=401, detail="Invalid or expired API key")
return AuthenticatedUser(user_id=user_record["user_id"], role=user_record["role"])
def require_permission(permission: Permission):
"""
Factory that returns a FastAPI dependency enforcing a specific permission.
Usage: Depends(require_permission(Permission.BATCH_ANALYSIS))
"""
def permission_checker(user: AuthenticatedUser = Depends(get_current_user)):
if not has_permission(user.role, permission):
raise HTTPException(
status_code=403,
detail=f"Your role '{user.role}' does not have permission: {permission.value}"
)
return user
return permission_checker
# --- Routes ---
class ChatRequest(BaseModel):
session_id: str
message: str
@app.post("/chat")
def chat(
request: ChatRequest,
user: AuthenticatedUser = Depends(require_permission(Permission.CHAT))
):
"""
Standard chat endpoint. Available to all roles.
Session isolation is enforced by passing user.user_id to every session operation.
"""
# Append user message — will fail if session_id belongs to a different user
success = sessions.append_message(
user_id=user.user_id,
session_id=request.session_id,
role="user",
content=request.message
)
if not success:
raise HTTPException(status_code=404, detail="Session not found")
history = sessions.get_history(user.user_id, request.session_id)
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=history
)
assistant_reply = response.choices[0].message.content
sessions.append_message(user.user_id, request.session_id, "assistant", assistant_reply)
return {"reply": assistant_reply}
class BatchRequest(BaseModel):
documents: list[str]
task: str
@app.post("/batch-analysis")
def batch_analysis(
request: BatchRequest,
user: AuthenticatedUser = Depends(require_permission(Permission.BATCH_ANALYSIS))
):
"""
Analyst/admin only endpoint. Viewers receive a 403 automatically.
"""
# Expensive operation that only analysts and admins can trigger
results = []
for doc in request.documents:
response = openai_client.chat.completions.create(
model="gpt-4o-mini",
messages=[{"role": "user", "content": f"{request.task}\n\n{doc}"}]
)
results.append(response.choices[0].message.content)
return {"results": results}
@app.get("/admin/sessions/{target_user_id}")
def get_user_sessions(
target_user_id: str,
user: AuthenticatedUser = Depends(require_permission(Permission.VIEW_ALL_SESSIONS))
):
"""Admin-only endpoint. Anyone without VIEW_ALL_SESSIONS gets a 403."""
# In a real app, you'd query all sessions for target_user_id
return {"message": f"Sessions for {target_user_id} (admin access confirmed for {user.user_id})"}
Tip
The require_permission function is a dependency factory — a function that returns a dependency function. This pattern lets you parameterize your security checks cleanly. One factory covers all your permissions.
A key management system isn't complete without the ability to revoke compromised keys and rotate old ones. This is simpler than it sounds:
def revoke_api_key(db: sqlite3.Connection, user_id: str, key_name: str) -> bool:
"""
Deactivate a specific API key. The user can no longer use it.
Returns True if a key was actually deactivated.
"""
cursor = db.execute("""
UPDATE api_keys
SET is_active = 0
WHERE user_id = ? AND key_name = ? AND is_active = 1
""", (user_id, key_name))
db.commit()
return cursor.rowcount > 0
def rotate_api_key(
db: sqlite3.Connection,
user_id: str,
old_key_name: str
) -> Optional[str]:
"""
Generate a new key, deactivate the old one.
Returns the new plaintext key if rotation succeeded.
"""
new_plaintext, new_hash = generate_api_key()
# Atomic-ish: deactivate old, add new
revoked = revoke_api_key(db, user_id, old_key_name)
if not revoked:
return None
store_api_key(
db,
user_id=user_id,
key_name=old_key_name + "-rotated",
hashed_key=new_hash,
role="analyst" # Preserve role — in production, look this up
)
return new_plaintext
Warning
When you rotate a key, there's a brief window where a service using the old key will fail before it picks up the new one. For critical production systems, implement a grace period: keep both the old and new key active for 15–30 minutes during rotation, then deactivate the old one. The is_active flag in our schema supports this trivially.
Properly handling secrets — including how to store your own API keys to OpenAI or Anthropic — is covered in depth at Managing API Keys and Authentication Secrets Securely in LLM Applications. That article is the companion to this one for the infrastructure side of secrets management.
Build a working version of this system with the following requirements. Start from the code in this article and extend it.
Setup (10 minutes)
init_db()fakeredis library if Redis isn't installed: pip install fakeredis, then replace redis.from_url(...) with fakeredis.FakeRedis(decode_responses=True))Test authentication (10 minutes)
4. Verify that Alice's key validates and returns her role
5. Verify that a modified/fake key returns None
6. Revoke Alice's key and verify it no longer validates
Test session isolation (15 minutes)
7. Create a session for Alice and a session for Bob
8. Append a message to Alice's session
9. Try to retrieve Alice's session using Bob's user_id — confirm you get None
10. Confirm Alice can retrieve her own session correctly
Test RBAC (10 minutes)
11. Run the FastAPI app with uvicorn app:app --reload
12. Using curl or the built-in Swagger UI at http://localhost:8000/docs, make a request to /batch-analysis with Bob's key. You should receive a 403.
13. Make the same request with Alice's key. You should receive a 200.
14. Make a request to /admin/sessions/someuser with Alice's key (403) and Carol's key (200).
Mistake: Storing plaintext API keys in the database
Symptom: You can read keys directly from your api_keys table.
Fix: Always hash before storing. Use hashlib.sha256(key.encode()).hexdigest(). The only time the plaintext exists is in memory, during generation, and in the response to the user who requested it.
Mistake: Using the same namespace for all users in Redis
Symptom: Users occasionally see each other's conversation history, especially under load.
Fix: Every Redis key must include user_id. Audit your _make_key function to ensure user_id is non-optional. Add a test that deliberately tries to cross-access sessions.
Mistake: Returning different error messages for "invalid key" vs. "revoked key" Symptom: An attacker can determine whether a key existed by looking at the error message. Fix: Always return the same 401 response regardless of why the key is invalid. "Invalid or expired API key" covers both cases without leaking information.
Mistake: Not updating last_used_at
Symptom: You have no way to identify dormant or abandoned API keys for cleanup.
Fix: Add UPDATE api_keys SET last_used_at = ? WHERE hashed_key = ? inside validate_api_key. This also gives you an audit trail.
Mistake: Building RBAC as if-else chains instead of a permission map
Symptom: Adding a new feature requires touching dozens of places in your code.
Fix: Centralize your permission definitions in ROLE_PERMISSIONS. Adding a new permission is one line in the dictionary. Granting it to a role is one more. Nothing else changes.
Tip
If you're also implementing input validation and output filtering on top of this auth layer, the two systems complement each other well. The Guardrails and Safety Layers article covers exactly that and assumes a user identity model like the one we built here.
You've built a production-ready security layer for a multi-user LLM application. Here's what you've assembled:
user_id:session_id, where no user can access another's stateThese three systems are independent but designed to compose: your get_current_user dependency produces an AuthenticatedUser object that flows into both the session store and the RBAC check, so the whole security layer activates on every request with a single Depends() call.
Where to go from here:
The security layer you've built is the foundation for more sophisticated multi-tenant systems. Once you have user identity flowing correctly through your application, you can layer on usage tracking and cost attribution per user — patterns explored in Building a Multi-Tenant LLM Platform.
For observability — being able to trace exactly what each user requested, what the model returned, and at what cost — Implementing LLM Observability: Tracing, Logging, and Monitoring Requests in Production is the logical next lesson. The user_id you're now passing around is exactly what you'll use to attribute traces to individual users.
Finally, if your application accepts arbitrary user input (and every chat application does), you're exposed to prompt injection attacks — cases where users craft messages designed to override your system prompt or exfiltrate other users' data. Prompt Injection Attacks: What They Are and How to Defend Against Them covers the threat model and mitigations that sit directly on top of what you've built here.
Security isn't a feature you add at the end. It's the skeleton your application grows around. You've got the skeleton. Now build something on it.