| name | build-custom-user-verifier |
| description | Build a custom user verifier server for Arcade.dev in any language. Use when implementing Arcade's user verification flow, building an /auth/verify endpoint, integrating Arcade tool authorization into a production app, or troubleshooting verification issues like missing user_id or session cookies during the OAuth callback. |
Build a Custom User Verifier for Arcade.dev
This is a companion skill for the Secure Auth in Production documentation page.
How the verification flow works
When an app uses Arcade to authorize a tool on behalf of a user:
- Your app calls
client.tools.authorize(tool_name=..., user_id="alice") or sets the Arcade-User-ID: alice header on a gateway request
- Arcade returns an authorization URL for the user to visit (e.g. Google OAuth consent)
- After the user completes consent, Arcade redirects the user's browser (HTTP 303) to your custom verifier URL with a
flow_id query parameter:
GET https://your-app.com/auth/verify?flow_id=af_abc123
- Your server identifies the user from its own session (cookie, token, etc.) — Arcade does NOT send any user identity
- Your server calls the Arcade API to confirm: "the user on this browser session is
alice"
- Arcade checks that
alice matches the user_id from step 1 — if so, verification succeeds
This prevents OAuth phishing: an attacker starting a flow with their own user_id, then tricking a victim into completing consent, which would give them the ability to execute actions on your user's upstream service account (Gmail, Notion, etc).
Prerequisites
- An Arcade API key (set as
ARCADE_API_KEY environment variable)
- Your app must have its own user session mechanism (cookies, JWTs, etc.)
- Users must be signed into your app before starting any Arcade tool authorization
Build the verifier endpoint
You need one server-side route: a GET endpoint that handles the browser redirect from Arcade.
Pseudocode
ROUTE GET /auth/verify:
// 1. Get the flow_id from the query string (provided by Arcade)
flow_id = request.query_params["flow_id"]
IF flow_id is missing:
RETURN error 400 "Missing flow_id parameter"
// 2. Get the user's identity from YOUR app's session.
// Arcade does NOT send this. Your server must already know
// who the user is via session cookie, JWT, or similar.
user_id = get_user_id_from_your_session(request)
IF user_id is missing:
RETURN error 401 "User is not signed in"
// 3. Call Arcade's confirm_user API (server-side, using your API key)
TRY:
result = arcade_client.auth.confirm_user(
flow_id = flow_id,
user_id = user_id
)
CATCH error:
RETURN error 400 "Verification failed"
// 4. Wait for the auth flow to complete
TRY:
auth_response = arcade_client.auth.wait_for_completion(result.auth_id)
IF auth_response.status == "completed":
RETURN success page or redirect to your app
ELSE:
RETURN error page "Authorization was not completed"
CATCH error:
IF result.next_uri exists:
REDIRECT to result.next_uri
RETURN error 500 "Could not confirm authorization status"
Python
from arcadepy import Arcade
client = Arcade()
result = client.auth.confirm_user(flow_id=flow_id, user_id=user_id)
auth_response = client.auth.wait_for_completion(result.auth_id)
JavaScript/TypeScript
import { Arcade } from "@arcadeai/arcadejs";
const client = new Arcade();
const result = await client.auth.confirmUser({
flow_id: flowId,
user_id: userId,
});
const authResponse = await client.auth.waitForCompletion(result.auth_id);
REST API (any language)
If your language doesn't have an Arcade SDK, call the REST API directly:
POST https://cloud.arcade.dev/api/v1/oauth/confirm_user
Authorization: Bearer <your_arcade_api_key>
Content-Type: application/json
{
"flow_id": "<flow_id from the query string>",
"user_id": "<user_id from your app session>"
}
Success response (200):
{
"auth_id": "ac_2zKml...",
"next_uri": "https://..."
}
Error response (400) — user_id does not match the one that started the flow:
{
"code": 400,
"msg": "An error occurred during verification"
}
Critical requirements
-
The user_id must match on both sides. The ID passed to confirm_user must be identical to the one your app used when starting the authorization flow (client.tools.authorize(user_id=...) or the Arcade-User-ID header).
-
The user must already be signed into your app. Arcade does a browser redirect, so the browser sends cookies for your domain — but only if they already exist. No session means no user identity at verification time.
-
The confirm_user call must be server-side. It uses your Arcade API key, which must never be exposed to the client.
-
Your verifier URL must be publicly reachable. Arcade redirects the user's browser to it. For local development, use a tunnel like ngrok.
Register in the Arcade Dashboard
- Go to Auth > Settings
- Select Custom verifier
- Enter the full URL of your endpoint (e.g.
https://your-app.com/auth/verify)
Common mistakes
| Mistake | Symptom | Fix |
|---|
| User not signed into your app before the auth flow | user_id is missing from session at verification time | Ensure users authenticate with your app first |
Different user_id on start vs. verify | Arcade returns 400 on confirm_user | Use the same identifier in both places |
Calling confirm_user from the browser | API key exposed in client-side code | Move the call to your server |
| Verifier URL not publicly accessible | Arcade redirect fails or times out | Use ngrok or deploy to a public host |
Not handling wait_for_completion | Verification appears to hang | Poll or await wait_for_completion, fall back to next_uri |
Reference implementation
The custom-user-verifier repo is a complete working example in Python/Flask. Key file: app.py, function verify_user(). Clone it and read the source to understand the full pattern.
Further reading