DEV Community

Cover image for How to Authenticate AI Agents in B2B SaaS: Delegated Auth, Scoped Tokens, and Audit Trails
Sachin
Sachin

Posted on

How to Authenticate AI Agents in B2B SaaS: Delegated Auth, Scoped Tokens, and Audit Trails

Let's start with a scenario that should sound familiar.

You've shipped an AI agent inside your B2B SaaS product. It summarizes meetings, drafts content, creates notes in Notion, and manages knowledge workflows — all on behalf of your users. It's fast. It's delightful. Your customers love it.

Now ask yourself: when your agent creates a Notion page on behalf of John from XCorp — does Notion's API actually know it's John? Does it know it's XCorp? Does it know the agent is only supposed to write to specific workspaces and not read everything John has ever written?

If your answer involves a shared API key, a service account with broad permissions, or a vague "we trust the agent to behave" — this article is for you.

How Most Teams Handle Agent Auth Today (And Why It's a Liability)

Most teams building customer-facing AI agents have stitched together authentication in one of three ways. They all work in development. They all carry risk that doesn't surface until something goes wrong in production.

Pattern 1: The Hardcoded API Key

This is the fastest path from prototype to production, which is exactly why it's so common. Your agent needs to create Notion pages, read databases, manage workspaces — you grab a Notion integration token, stuff it in an environment variable, and ship it.

The token is tied to a single Notion integration. It has whatever permissions that integration has. It has no concept of which user triggered the action, and no concept of which org the request is for. It will keep working indefinitely, or until someone revokes the integration and everything breaks at once.

A hardcoded API key is like giving your delivery driver a master key to every apartment in the building because you don't want to bother with individual unit codes. There's no revocation per user, no scoping per tenant. And if someone asks "which agent action ran on behalf of John from Sales at XCorp last Tuesday?" — the answer is a spreadsheet of server logs and a best guess.

Pattern 2: The Shared Service Account

A single Notion integration with broad workspace access handles every agent action, for every user, for every org. The downstream system sees requests from one identity. You've lost all user context the moment the agent touches an external tool.

If you're in a multi-tenant environment — and if you're building B2B SaaS, you are — this means your agent is running as a single undifferentiated identity across every customer organization you have. That's not multi-tenancy. That's a liability waiting to manifest.

Pattern 3: The "Trust the Agent" Architecture

You trust the application layer to enforce access controls. The agent determines from context which user it's acting for, and you trust it won't do anything it shouldn't.

This works precisely until it doesn't. An edge case in the LLM's context handling. A subtly malformed request. A prompt injection attack that convinces the agent it's acting for a different user. There's no cryptographic guarantee of identity at the infrastructure level — authorization is enforced by application logic you wrote, and are relying on the model to respect.

The Multi-Tenancy Failure Mode

Here's what the failure looks like in practice — and it's rarely dramatic. It's a quiet data bleed.

Your AI agent is helping a content manager at TechCorp. The agent creates a Notion page and writes to a database. Somewhere in the permission model there's a misconfiguration — the agent's shared integration token has access to a Notion workspace that doesn't properly filter by organization. Because the agent doesn't carry a user-scoped token, Notion's API has no way to enforce tenant boundaries at the credential level. The agent writes data. Some of it lands in the wrong workspace.

This isn't a hypothetical — it's a class of bugs that appears in any system where authorization decisions are made at the application layer rather than the infrastructure layer. Agents make it worse because they operate autonomously, at speed, across many tenants, often without a human in the loop to catch when something looks off.

Tenant isolation isn't a nice-to-have in B2B SaaS. It's the whole point. Every enterprise customer assumes it's airtight. If your agent auth model relies on application-level trust rather than cryptographically verified, tenant-scoped credentials, there's a gap — and it will be found eventually, either by a security audit or by an incident.

What Correct Agent Auth Actually Looks Like

The Identity Problem, Stated Clearly

When a human logs into your app, authentication is straightforward. They prove who they are, you issue a session token tied to their identity, and every downstream request carries that identity as a cryptographic claim. The system knows who is asking, what they're allowed to see, and can log it against their account.

An AI agent acting on a user's behalf introduces a new actor into this chain. The agent isn't the user. It isn't your service. It's something operating in between, and classical auth models weren't designed for it.

The mental model you need: delegated authorization. The user explicitly grants the agent permission to act on their behalf. The agent receives a token that carries the user's identity and org context. That token is scoped to exactly the actions the user authorized. The downstream system doesn't trust the agent — it trusts the token, which was issued through a consent flow the user explicitly approved.

The question isn't "does the agent have permission to do this?" It's "did this specific user, in this specific org, authorize this agent to do exactly this?"

Delegated Authorization in Practice

The mechanism is OAuth — specifically, OAuth flows adapted for the agent context. The flow works like this:

A user interacts with your product and authorizes the agent to access external tools on their behalf. Your system initiates an OAuth flow that ties the resulting token to that specific user, in their specific org, with a specific set of scopes. The agent receives this token and uses it for downstream API calls — the token carries the user's identity, and the downstream system can verify it cryptographically. When the user revokes consent, the token is immediately invalidated. The agent loses access at the exact moment authorization is withdrawn.

This is fundamentally different from a service account. The agent doesn't accumulate permissions over time. It has exactly what this user authorized for this session, and nothing more.

Scoping: How Least Privilege Becomes Real

Token scoping is the mechanism that makes least privilege concrete at the agent layer. Rather than issuing a token that says "this agent can do anything this user can do," you issue a token that says "this agent can create Notion pages in approved workspaces, on behalf of this user, in this org, until revoked."

This matters because agents make mistakes. LLMs hallucinate. Prompt injection is a real attack vector. A well-scoped token acts as a hard boundary at the infrastructure layer — even if the agent logic goes sideways, the token cannot be used to take actions it was never authorized to take.

In a multi-tenant context, scoping also means every token is inherently org-scoped. There's no mechanism by which a token issued on behalf of John at XCorp can write into TechCorp's Notion workspace. The tenant boundary is encoded in the credential, not enforced by application logic you might forget to write.

What a Token Should Actually Carry

A properly constructed agent token is typically a short-lived OAuth access token backed by a JWT. Here's what the payload should look like:

{
  "sub": "user_01HXYZ123",          // stable user identifier  not email, not name
  "org_id": "org_xcorp_456",        // tenant context, baked in at issuance
  "scope": "notion:pages:write notion:workspace:read-specific",
  "agent_id": "agent_content_mgr",  // which agent received this delegation
  "auth_event_id": "authz_789",     // reference to the consent event that produced this token
  "iat": 1714000000,
  "exp": 1714003600                 // 1 hour; vault handles refresh, not your app
}
Enter fullscreen mode Exit fullscreen mode

Each field is doing specific work. sub makes every downstream action attributable to an individual — not a service account, not a role. org_id encodes the tenant boundary in the credential itself rather than relying on application logic to filter correctly. scope constrains what the agent can do at the infrastructure layer, so even a misbehaving agent can't exceed what the user approved. auth_event_id is the key to clean revocation: revoke that authorization event, and every token derived from it is immediately invalid, regardless of whether it's technically unexpired. The vault tracks this mapping; your application doesn't have to.

The short exp limits blast radius if a token leaks. The vault handles silent refresh — your application always gets a live token via get_connected_account, never needs to detect expiry or trigger a refresh cycle itself.

Revocation

This one gets underweighted in most agent auth discussions. With a hardcoded API key or a service account, revocation is a blunt instrument — you revoke the key, everything breaks, you scramble to reissue and reconfigure. With delegated tokens tied to per-user authorization events, revocation is precise: the user withdraws consent, that token is invalidated, the agent loses access for exactly that user and no one else. Nothing else in your system is disrupted.

This is the property that makes enterprise customers comfortable. They want to be able to say "if I change my mind, I can revoke access in one click." Delegated OAuth gives you that. Service accounts do not.

The Audit Trail You'll Actually Need

At some point a customer is going to ask what your agent did. Maybe it's a compliance audit. Maybe something went wrong and they're reconstructing what happened. Maybe their InfoSec team wants to review the logs before they sign the renewal.

With properly scoped, delegated tokens, every agent action is traceable to a specific authorization event. Each log entry should carry: the user who authorized, the org they belong to, the token ID used, the scope exercised, the timestamp, and whether the authorization was still valid at the time of the action.

"What did the agent do on behalf of John from Sales at XCorp last Tuesday?" becomes a query. Not an investigation.

Implementing This Pattern

Rolling delegated agent auth from scratch is doable — you're essentially building an OAuth authorization server with a token vault, per-tenant scoping logic, rotation policies, and audit hooks. Most teams building on top of this pattern reach for an existing implementation rather than owning that infrastructure, especially when it needs to be multi-region, SOC 2 compliant, and available at 99.99%.

The example below uses Scalekit's Agent Auth, which handles the OAuth flows, token vault, rotation, and audit logging described above. The pattern is identical whether you use Scalekit or build it yourself — the goal is to make the mechanics concrete.

A Python Example: Creating a Notion Page on Behalf of a User

This example walks through a minimal but complete implementation of the full flow: user consent, token issuance, scoped API call, no hardcoded keys.

Set Up Your Environment

pip install scalekit-sdk-python python-dotenv requests
Enter fullscreen mode Exit fullscreen mode
import scalekit.client
import os
import requests
from dotenv import load_dotenv

load_dotenv()

scalekit_client = scalekit.client.ScalekitClient(
    client_id=os.getenv("SCALEKIT_CLIENT_ID"),
    client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
    env_url=os.getenv("SCALEKIT_ENV_URL"),
)
actions = scalekit_client.actions
Enter fullscreen mode Exit fullscreen mode

Step 1: Get the User's Authorization

Before the agent touches anything, the user needs to explicitly grant access. get_authorization_link generates a Notion OAuth consent URL. Scalekit handles the entire OAuth handshake — exchanging the auth code, storing the resulting token securely in the vault, and refreshing it automatically when it expires.

link_response = actions.get_authorization_link(
    connection_name="notion",
    identifier="Sachin"  # stable identifier for this user in your system
)

print(f"Authorization link: {link_response.link}")
input("Press Enter after completing authorization...")
Enter fullscreen mode Exit fullscreen mode

The user clicks the link, sees Notion's standard OAuth consent screen, approves access, and that's it. They've defined what the agent is allowed to do. Everything from here is bounded by that decision.

Note: Make sure the notion connection is set up in your Scalekit dashboard under Agent Actions → Connections → Create Connection. Use Scalekit's built-in credentials for faster development — no need to create your own Notion OAuth app.

Notion Connector Setup

Notion Connector Setup

Step 2: Retrieve a Live, Scoped Token

response = actions.get_connected_account(
    connection_name="notion",
    identifier="Sachin"
)
connected_account = response.connected_account
tokens = connected_account.authorization_details["oauth_token"]
access_token = tokens["access_token"]
Enter fullscreen mode Exit fullscreen mode

This token represents the user's delegated identity. It's scoped to what they approved on the Notion consent screen — their workspaces, their pages, nothing else. Scalekit keeps it fresh in the background; you always get a valid token without managing rotation yourself.

Step 3: Act on the User's Behalf

headers = {
    "Authorization": f"Bearer {access_token}",
    "Content-Type": "application/json",
    "Notion-Version": "2022-06-28",  # Always pin the Notion API version
}

# Find an accessible parent page to create under
search_response = requests.post(
    "https://api.notion.com/v1/search",
    headers=headers,
    json={
        "filter": {"value": "page", "property": "object"},
        "page_size": 1
    }
)
results = search_response.json().get("results", [])

if not results:
    print("❌ No accessible pages found. Make sure the user shared a page with the integration.")
else:
    parent_page_id = results[0]["id"]

    # Create a new page under the found parent
    create_response = requests.post(
        "https://api.notion.com/v1/pages",
        headers=headers,
        json={
            "parent": {"page_id": parent_page_id},
            "properties": {
                "title": {
                    "title": [{"text": {"content": "Page Created by AI Agent 🤖"}}]
                }
            },
            "children": [
                {
                    "object": "block",
                    "type": "paragraph",
                    "paragraph": {
                        "rich_text": [
                            {
                                "type": "text",
                                "text": {
                                    "content": "This page was created by an AI agent on behalf of Sachin using Scalekit Agent Auth. The agent had exactly the permissions this user approved — nothing more."
                                }
                            }
                        ]
                    }
                }
            ]
        }
    )

    new_page = create_response.json()
    print(f"\n✅ Notion page created successfully!")
    print(f"Page ID  : {new_page.get('id')}")
    print(f"Page URL : {new_page.get('url')}")
    print(f"Created  : {new_page.get('created_time')}")
Enter fullscreen mode Exit fullscreen mode

Notion's backend sees this request as the authorized user — their workspaces, their permissions, scoped to exactly what they approved. The agent cannot access private pages, other users' workspaces, or anything outside the approved scope. That constraint isn't enforced by application logic — it's encoded in the credential itself.

Skipping the Raw API Calls

If you'd rather not manage Notion's API directly, Scalekit ships pre-built connectors for supported services. The same flow using Scalekit's built-in tools:

import scalekit.client
import os
from dotenv import load_dotenv

load_dotenv()

# Initialize Scalekit client using environment variables
scalekit_client = scalekit.client.ScalekitClient(
    client_id=os.getenv("SCALEKIT_CLIENT_ID"),
    client_secret=os.getenv("SCALEKIT_CLIENT_SECRET"),
    env_url=os.getenv("SCALEKIT_ENV_URL"),
)
actions = scalekit_client.actions

# Generate Notion OAuth consent link for this user
link_response = actions.get_authorization_link(
    connection_name="notion",
    identifier="Sachin"
)
print(f"Click this link to authorize Notion: {link_response.link}")
input("Press Enter after completing authorization...")

# Step 1: Use Scalekit's built-in tool to search for existing Notion pages
# No raw API call needed — Scalekit handles auth injection internally
search_response = actions.execute_tool(   # <-- Scalekit tool call
    tool_name="notion_page_search",       # <-- built-in Notion tool
    identifier="Sachin",                  # <-- user identity passed here
    tool_input={
        "page_size": 3,
        "sort_direction": "descending",
        "sort_timestamp": "last_edited_time"
    }
)

# Response data lives in search_response.data — no json.loads() needed
results = search_response.data.get("results", [])
print(f"Pages found: {len(results)}")

if not results:
    print("❌ No pages found. Please share a page on the Notion consent screen.")
else:
    parent_page_id = results[0]["id"]
    print(f"✅ Parent page found: {parent_page_id}")

    # Step 2: Use Scalekit's built-in tool to create a new Notion page
    new_page_response = actions.execute_tool(   # <-- Scalekit tool call
        tool_name="notion_page_create",         # <-- built-in Notion tool
        identifier="Sachin",
        tool_input={
            "parent_page_id": parent_page_id,   # <-- from Step 1 result
            "properties": {
                "title": {
                    "title": [{"text": {"content": "Page Created by AI Agent 🤖"}}]
                }
            },
            "child_blocks": [
                {
                    "object": "block",
                    "type": "paragraph",
                    "paragraph": {
                        "rich_text": [{"type": "text", "text": {
                            "content": "This page was created by an AI agent on behalf of Sachin using Scalekit Agent Auth. The agent had exactly the permissions this user approved — nothing more."
                        }}]
                    }
                }
            ],
            "notion_version": "2022-06-28"
        }
    )

    new_page = new_page_response.data   # <-- response data directly accessible
    print(f"\n✅ Notion page created successfully!")
    print(f"Page ID  : {new_page.get('id')}")
    print(f"Page URL : {new_page.get('url')}")
    print(f"Created  : {new_page.get('created_time')}")
Enter fullscreen mode Exit fullscreen mode

execute_tool handles auth injection internally — Scalekit looks up the connected account for that identifier, retrieves the live token from the vault, and injects it into the request. The response comes back as a Python dict. No token handling, no JSON parsing, no API version management.

The raw API approach is more transparent and useful if you want to understand what's happening or work with APIs Scalekit doesn't yet cover. The built-in tools are more practical if you're moving fast and the connector exists. Use whichever fits the context.

What Actually Happens When You Run This

No magic, no hand-waving. Here's exactly what the flow looks like end to end once you run the program:

1. You run the program

Your terminal prints a Scalekit-generated authorization link and waits. The program is paused — nothing happens until you say so.

Running the program

2. You open the link — Scalekit shows an authorization interface

You follow the link in your browser. Scalekit presents a clean authorization screen that tells you exactly what the agent is asking for — which app (Notion), which permissions, on whose behalf. No fine print. No vague "access to everything."

Scalekit Auth

You click Allow access.

Allowing Access

3. Notion's OAuth flow completes in the background

Scalekit handles the entire OAuth handshake with Notion — exchanges the auth code for an access token, stores it securely in the token vault, and marks the connected account for identifier="Sachin" as ACTIVE. You don't see any of this. It just works.

Auth Completed

4. You come back to the terminal and press Enter

The program resumes, fetches the live token from the vault, and moves on to the Notion API call.

Continuing the Python program

5. The agent creates a page in Notion on your behalf

You access the page URL. A new page is sitting right there — created by the agent, using your delegated identity, with exactly the permissions you approved and nothing more.

Page Created by Agent

The agent acted as you. Notion knew it was you. And you stayed in control the whole time.

Connection Created


Conclusion

Agents are no longer just internal tools — they're acting on behalf of real users, inside real organizations, touching real data. A hardcoded API key got you to the demo. It won't get you to production.

The pattern is simple: delegated auth, scoped tokens, full audit trail. Scalekit gives you the infrastructure to do it right — without rebuilding your entire auth stack.

Your users are trusting your agent with their data. Make sure it's worthy of that trust.

Top comments (0)