Announcing CIMD support for MCP Client registration
Learn more

How to Deploy an AI Agent on Render With Auth and Tools

Saif Ali Shaik
Founding Developer Advocate

TL;DR

A multi-user GitHub PR summarizer running as one Render web service. Every browser session connects its own GitHub account. The server mints the Scalekit identifier and never accepts one from the browser.

  • The server mints an opaque usr_… identifier per session and keeps it server-side
  • The browser only carries a signed, HTTP-only sid cookie
  • GitHub OAuth redirects to Scalekit, not to your Render URL
  • Tool calls pass the identifier to Scalekit, which injects that user's token
  • The model writes prose. It never sees a token and never picks the tools.

By the end of this tutorial

  • An agent running as a Render web service with three Scalekit environment variables.
  • A GitHub connector that each of your users authorizes for themselves.
  • A session-bound identifier that the browser cannot choose or tamper with.
  • Working tool calls that read PR diffs and comments as the connected user.
  • A pattern you can repoint at Slack, Gmail, Notion, or any other connector.

Note: The code in this post is lifted from the reference repo, which runs as a live Render service. Run the setup checklist in that repo against your own Scalekit environment and GitHub OAuth app before treating your deploy as proven. Model ids and dashboard wording change; confirm both before production.

Prerequisites

  • A Render account (the free tier is enough for this walkthrough)
  • A Scalekit account with access to an environment you can configure
  • A GitHub account that can create an OAuth app, personally or in an org
  • An OpenAI project key, or a LiteLLM virtual key and proxy URL
  • Node.js 20 or newer for local runs
  • A notepad, because you will copy values between three dashboards

Three Products Own One Piece of the OAuth Handshake

Most of the confusion in this setup comes from one wrong assumption: that the browser completes OAuth "with your Render URL." It does not. GitHub redirects to Scalekit. Scalekit stores the token. Your app on Render never handles a GitHub token at all.

Here is who owns what:

Product
What it owns
Scalekit
AgentKit credentials, the GitHub connector, the OAuth redirect URI, user verification, the token vault
GitHub
The OAuth app whose client ID and secret Scalekit's connector uses, and the authorization callback URL
Render
The Node process, the environment secrets, and the public …onrender.com URL

Two flows run through those three products. At connection time, a user authorizes GitHub and Scalekit stores the result against an identifier your server chose. At runtime, your server looks up that identifier from the session and asks Scalekit to execute a tool as that user.

Note: "The identifier is the key that lets your code act as one of your users. It is minted server-side, stored server-side, and never sent by the browser."

Key point: the identifier is the sensitive value in this design, not the cookie. The cookie is a random opaque session id with an HMAC over it. The identifier that unlocks a stored GitHub token stays in your session store.

This Is for Builders Adding Real Users to an Existing Agent

You already have an agent. It runs locally, it calls a model, and the logic is basically right. What you do not have is a place to run it and a way for other people to bring their own accounts to it.

The "multiple users" in this post are your users. They might be teammates on one internal tool, or customers of a product you are shipping. Either way, each of them authorizes their own GitHub account once, and every later tool call runs with that person's identity and permissions.

Running an agent for yourself alone works with the same primitives. It is just not the case this post is built around, because a single-user agent never hits the impersonation problem that shapes the whole design.

Hosting Isn't the Hard Part. Per-User GitHub Access Is.

Put one shared GitHub token in a Render environment variable and the agent works on the first try. It also means every user sees every repo that token can see. There is no per-user permission, no audit trail, and no way to revoke one person without breaking everyone.

The obvious fix is per-user tokens, which is where the real work starts. You now own an OAuth flow, a token store, refresh logic, and a revocation path. That is a security product, sitting next to the agent you actually wanted to build.

There is a sharper failure mode hiding underneath. Suppose you store per-user tokens correctly, keyed by some user id, and the browser tells the server which id to use. Any user can now send someone else's id and act as that person. The token store is fine. The lookup is the vulnerability. This is the exact problem explored in Who Holds the Token? Credential Ownership Across Agent Tool-Calling Patterns.

Render Runs the Process. Scalekit Owns Identity and Tokens.

Split the two problems and each one gets small.

Render runs one Node web service. It holds your app secrets, serves a public URL, and restarts on deploy. Nothing about per-user auth lives there.

Scalekit holds the GitHub connector, the OAuth lifecycle, and a token vault keyed by an identifier that you choose. Your app's whole job is to choose that identifier safely: mint it server-side, store it in the session, and resolve it from the session on every request.

That is why the browser never sends a user id in this sample. There is no field for one in the UI and no parameter for one in the API. The only thing the browser carries is a signed cookie that points at a server-side session, and the identifier lives inside that session.

Step 1: Copy Your Scalekit API Credentials

Open app.scalekit.com and pick the environment you will use. Go to Developers → API Credentials and copy three values:

SCALEKIT_ENVIRONMENT_URL=https://your-env.scalekit.com SCALEKIT_CLIENT_ID=skc_... SCALEKIT_CLIENT_SECRET=...

Stay in this environment for every remaining step. Credentials from one environment with a connector from another produce OAuth errors that look like connector bugs.

Step 2: Add the GitHub Connector and Copy Its Redirect URI

In the same environment, go to AgentKit → Connectors and add a GitHub connector. Copy two things:

  • The connection name, for example github-qkHFhMip. This becomes GITHUB_CONNECTION_NAME.
  • The redirect URI shown on the connector. It is a Scalekit URL, not your Render URL.

Leave that tab open. The redirect URI is what GitHub needs next.

Checkpoint: you should now have four values written down: three credentials and one connection name.

Step 3: Point the GitHub OAuth App at Scalekit, Not Render

This is the step that costs people an afternoon.

Go to GitHub Settings → Developer settings → OAuth Apps and create an app. Set the Authorization callback URL to the exact Scalekit redirect URI from step 2.

Do not set it to any of these:

  • https://your-service.onrender.com
  • https://your-service.onrender.com/user/verify
  • http://localhost:3000

GitHub allows one primary callback, and it has to be Scalekit, because Scalekit is what exchanges the code and stores the token. Your app finds out that the connection succeeded through user verification or polling, not from GitHub calling Render.

Then paste the OAuth app's client ID and secret into the Scalekit connector and save.

Step 4: Turn On User Verification Before the First Connect

Go to AgentKit → Settings → User verification and pick a mode.

Mode
Use when
What happens after GitHub OAuth
Scalekit users only
Local development and demos
Scalekit marks the connection active; your app polls until it sees that
Custom user verification
Production
Scalekit redirects to your /user/verify route, and your server calls verifyConnectedAccountUser

Do this before the first Connect GitHub click. Skipping it is the single most common reason the UI sits on "Waiting for GitHub authorization" after an OAuth flow that looked successful.

Custom verification needs Scalekit to reach your public URL, so it wants a deployed Render service or a tunnel. For local work, start with Scalekit users only and switch before production.

Step 5: Mint the Identifier on the Server, Never in the Browser

Now the code. Two rules carry the whole security story: the server mints the identifier, and the server is the only place it is ever read from.

The session layer issues a random session id, signs it into a cookie, and keeps the identifier in a server-side entry:

import { createHmac, randomBytes, timingSafeEqual } from "node:crypto"; const COOKIE_NAME = "sid"; interface SessionEntry { identifier: string; pendingState?: string; pendingStateExpiresAt?: number; connectedAt?: number; } // In-memory store — fine for a single instance. Use Redis in production. const store = new Map<string, SessionEntry>(); function sign(sessionId: string): string { const mac = createHmac("sha256", process.env.SESSION_SECRET!) .update(sessionId) .digest("base64url"); return `${sessionId}.${mac}`; } export function requireSession(req: Request, res: Response) { const raw = parseCookieHeader(req.headers.cookie)[COOKIE_NAME]; let sessionId = raw ? unsign(raw) : null; let entry = sessionId ? store.get(sessionId) ?? null : null; if (!sessionId || !entry) { sessionId = randomBytes(32).toString("base64url"); entry = { identifier: "" }; store.set(sessionId, entry); } res.setHeader( "Set-Cookie", [`${COOKIE_NAME}=${sign(sessionId)}`, "HttpOnly", "SameSite=Lax", "Path=/"].join("; "), ); return { sessionId, entry }; } /** Mint a stable opaque identifier for a session. Same value on repeat calls. */ export function mintIdentifier(entry: SessionEntry): string { if (!entry.identifier) { entry.identifier = `usr_${randomBytes(16).toString("hex")}`; } return entry.identifier; }

The connect route mints the identifier, binds a single-use state value to the session, and asks Scalekit for an authorization link:

app.post("/api/auth", async (req, res) => { const { entry } = requireSession(req, res); const identifier = mintIdentifier(entry); // One-time CSRF state bound to this session, valid for 10 minutes. const state = crypto.randomUUID(); setPendingState(entry, state); const userVerifyUrl = `${getRequestOrigin(req)}/user/verify`; await scalekit.actions.getOrCreateConnectedAccount({ connectionName: GITHUB_CONNECTION_NAME, identifier, }); const { link } = await scalekit.actions.getAuthorizationLink({ connectionName: GITHUB_CONNECTION_NAME, identifier, state, userVerifyUrl, }); res.json({ authLink: link }); });

Note what is absent: the request has no body. There is nothing for a caller to supply and nothing to validate, because identity comes from the cookie.

The verification callback reads identity from the session and the auth_request_id from the query string. It never trusts an identifier from the URL:

app.get("/user/verify", async (req, res) => { const authRequestId = getSingleQueryParam(req.query.auth_request_id); const state = getSingleQueryParam(req.query.state); if (!authRequestId || !state) return res.status(400).send("Missing auth_request_id or state"); const { entry } = requireSession(req, res); // Read identity from session — never from the URL. if (!entry.identifier) return res.status(400).send("No pending authorization for this session"); if (!consumePendingState(entry, state)) { return res.status(400).send("Invalid or expired state — authorization failed"); } await scalekit.actions.verifyConnectedAccountUser({ authRequestId, identifier: entry.identifier, }); markConnected(entry); res.type("html").send(renderAuthCompletePage()); });

The original tab polls a status route while the OAuth tab is open. That route checks the session first, then asks Scalekit whether the connected account went active:

app.get("/api/auth/status", async (req, res) => { const { entry } = requireSession(req, res); if (isConnected(entry)) return res.json({ connected: true }); if (entry.identifier && (await isAccountActive(entry.identifier))) { markConnected(entry); return res.json({ connected: true }); } res.json({ connected: false }); });

Both detection paths run in parallel and whichever fires first wins. That is what lets one codebase work in either verification mode.

Checkpoint: run locally, click Connect GitHub, finish consent in the new tab, and the original tab should flip to a connected banner within a few seconds.

Step 6: Call GitHub Tools With That Identifier

Tool calls take the identifier and the connection name. Scalekit injects the stored token for that pair, so your code never touches one. This is the core of tool calling authentication for AI agents — the agent never holds credentials directly.

export async function githubTool( identifier: string, toolName: string, toolInput: Record<string, unknown>, ) { const res = await scalekit.actions.executeTool({ toolName, toolInput, connector: GITHUB_CONNECTION_NAME, identifier, }); return res.data ?? {}; }

For endpoints without a named tool, the same connected account backs a raw authenticated request:

export async function githubRequest(identifier: string, path: string, options = {}) { const res = await scalekit.actions.request({ connectionName: GITHUB_CONNECTION_NAME, identifier, path, method: options.method ?? "GET", headers: options.headers, }); return res.data; }

The summarizer uses both. It lists open PRs with a named tool, then pulls each diff and comment thread through raw requests. Render's workflow helper wraps each step with retries:

import { task } from "@renderinc/sdk/workflows"; const fetchOpenPRs = task( { name: "fetchOpenPRs", retry: { maxRetries: 3, waitDurationMs: 1000 } }, async function fetchOpenPRs(identifier: string, owner: string, repo: string) { const raw = await githubTool(identifier, "github_pull_requests_list", { owner, repo, state: "open", }); const list = normalizePRList(raw); return [...list] .sort((a, b) => b.comments + b.review_comments - (a.comments + a.review_comments)) .slice(0, 5); }, );

Only then does the model appear, and its job is narrow. It receives diffs and comment text and returns one paragraph per PR. It does not choose tools, and it never sees a credential:

const response = await client.chat.completions.create({ model: process.env.OPENAI_MODEL ?? "gpt-5-mini", messages: [ { role: "system", content: "You are summarizing GitHub pull request activity for a team lead. " + "For each pull request, write exactly one paragraph in plain language.", }, { role: "user", content: `Repository: ${owner}/${repo}\n\n${prBlocks}` }, ], });

The summarize route closes the loop. It reads the identifier from the session, and rejects the request outright if that session never connected:

app.post("/api/summarize", async (req, res) => { const { entry } = requireSession(req, res); if (!isConnected(entry)) { return res.status(401).json({ error: "Connect your GitHub account first (Step 1)" }); } const { owner, repo } = resolveRepoInput(req.body); const result = await summarizePRsTask({ identifier: entry.identifier, owner, repo }); res.json(result); });

The request body carries a repository. It does not carry a user.

Step 7: Deploy to Render With render.yaml

A blueprint keeps the environment reproducible and generates the session secret for you:

services: - type: web name: render-pr-summarizer runtime: node buildCommand: npm install && npm run build startCommand: node dist/main.js envVars: - key: SCALEKIT_ENVIRONMENT_URL sync: false - key: SCALEKIT_CLIENT_ID sync: false - key: SCALEKIT_CLIENT_SECRET sync: false - key: GITHUB_CONNECTION_NAME sync: false - key: OPENAI_API_KEY sync: false - key: OPENAI_MODEL value: gpt-5-mini - key: SESSION_SECRET generateValue: true

Two deployment details matter more than they look.

SESSION_SECRET signs the session cookie. With generateValue: true, Render creates a stable one. Deploy from the dashboard without it and the app falls back to a per-process secret, which means every restart logs everyone out.

OPENAI_BASE_URL should be deleted, not blanked, when you use OpenAI directly. An empty string still counts as set, and a leftover proxy URL sends your OpenAI key to the wrong host.

If you switched to custom user verification in step 4, confirm Scalekit can reach https://<your-service>.onrender.com/user/verify once the service is live.

Checkpoint: open the Render URL, connect GitHub, paste a public owner/repo, and a summary should appear. The model call can take up to two minutes on a large repo.

Clone and Run the Full Example

The complete app is one repo:

github.com/scalekit-developers/render-ai-agent-deploykit

Try the live demo first if you want to see the flow before configuring anything. There is a step-by-step cookbook and a video walkthrough covering the same setup.

To run it locally:

git clone https://github.com/scalekit-developers/render-ai-agent-deploykit cd render-ai-agent-deploykit npm install cp .env.example .env openssl rand -hex 32 # paste into SESSION_SECRET npm run dev # http://localhost:3000

The pattern is connector-agnostic. Swap github_pull_requests_list for a Slack, Gmail, or Notion tool, change the connection name, and the identifier plumbing does not move. Browse the connector catalog for what is available. For a related walkthrough of a multi-connector approach, see Build an Engineering Standup Agent - GitHub, GitLab, Jira, Slack.

Troubleshooting

Why does the UI stay on "Waiting for GitHub authorization" after OAuth succeeded?

User verification mode is not set. Go back to step 4. Scalekit cannot mark the account active until a mode is chosen, so both the polling path and the callback path stay silent.

Why do I get a redirect_uri mismatch from GitHub?

The GitHub OAuth app's callback URL points at your Render service or localhost. It has to be the Scalekit redirect URI from step 2. See step 3.

Why does the connector error even though the connection name looks right?

The credentials and the connector are probably in different Scalekit environments. Re-check steps 1 and 2 and confirm both came from the same environment.

Why does /api/summarize return 401?

That session never completed the connect flow, or the cookie was dropped. Confirm SESSION_SECRET is stable, then run the connect step again. Sessions also reset whenever the service restarts, because this sample stores them in memory.

Why does the LLM call fail with a model or token error?

Check whether OPENAI_BASE_URL is set. If it points at a proxy, OPENAI_API_KEY must be that proxy's virtual key, and OPENAI_MODEL must be an id that proxy lists. Mixing an OpenAI project key with a proxy base URL fails on every request.

Why does a private repo return "not found"?

The connected GitHub account cannot see it, or an org admin has not approved the OAuth app for private repository access. Public repos work with any connected account.

Tradeoffs and Limitations

Approach
Pros
Cons
When to use
One shared GitHub token in Render env
Works immediately
No per-user permissions, audit, or revocation
Prototypes only
Browser sends the user id
Simple to write
Any user can act as any other user
Never
Your own OAuth flow and token vault
Full control
You now maintain a security product
Rarely worth it
Scalekit connected accounts with a session-bound identifier
Per-user scopes, automatic refresh, one integration for many connectors
Another dependency in the stack
Production multi-user agents

Two limits in this sample are deliberate. Sessions live in memory, so they reset on restart and will not survive more than one instance; move them to Redis or a database before you scale past one. And the model only writes prose, which keeps the blast radius small but also means the agent cannot decide to call a different tool on its own. For a deeper look at the cost tradeoffs of building this yourself, see The Hidden Cost of Building OAuth Internally for AI Agents.

What's Next

No items found.
Agent
Auth Quickstart
On this page
Share this article
Agent
Auth Quickstart

Acquire enterprise customers with
zero upfront cost.

Every feature unlocked. No hidden fees.