Nodaro Docs
DocumentationNode ReferenceModelsAI Agents (MCP)DevelopersSelf-hostingResearch

OAuth apps

Register an OAuth app, send users to the Nodaro consent screen, exchange the code for a 90-day access token, and call the API on each user's behalf.

Available on Nodaro Cloud · Business edition

An OAuth app lets your product call the Nodaro API on behalf of other Nodaro users. Each user approves your app on a consent screen, your server exchanges the resulting code for an access token, and every call with that token acts as that user, limited to the scopes they granted. Nodaro implements the standard OAuth 2.0 authorization-code flow, with PKCE for clients that cannot keep a secret.

Developer apps are available on Nodaro Cloud and on Business edition installs.

OAuth or a personal API token

You are buildingUseToken format
A script, cron job, CI job or backend that uses your own accountA personal API tokenndr_ followed by 64 hex characters
A hosted product whose users have their own Nodaro accountsAn OAuth appndr_app_ followed by 64 hex characters

Use a personal API token when only your own account authenticates, and you need no consent screen and no revocation per user. See Authentication.

Use OAuth in these cases:

  • You build a hosted product, such as a web app, a SaaS or a marketplace, and your users have their own Nodaro accounts.
  • Each user grants your app only part of what their account can do.
  • Each user can cut off your app at any time without affecting other apps.

How the flow works

  1. The user clicks Connect to Nodaro on your site.
  2. Your site sends the browser to Nodaro's /oauth/authorize page with your client_id, redirect_uri, scope and state.
  3. Nodaro shows the consent screen, after a sign-in if needed. The screen shows your app's name, logo and requested scopes, and the account that will grant access. Use a different account signs the user out and returns to the same screen.
  4. The user clicks Allow, and Nodaro creates a one-time authorization code.
  5. The browser returns to your redirect_uri with ?code=...&state=....
  6. Your server exchanges the code for an access token at POST /v1/oauth/token, with your client_id and client_secret.
  7. Your server calls the Nodaro API with Authorization: Bearer ndr_app_... on the user's behalf.

Register your app

Open Developer Apps

On the Nodaro instance you target, open Settings › Developer Apps (/settings/developer-apps) and click Create App.

Fill in the form

Enter the name, the redirect URIs and the scopes. The fields are described in the table below.

Save the client secret

The App Created dialog shows the Client ID, which starts with app_, and the Client Secret, which starts with sec_. The secret is shown exactly once. Copy it to your secret manager before you close the dialog: Nodaro stores only a hash of it and cannot show it again.

FieldRequiredRules
NameYes1 to 100 characters. Shown on the consent screen.
DescriptionNoUp to 500 characters. Shown under the name on the consent screen.
Redirect URIsYes1 to 10, one per line. Each is an https:// address, or an http://localhost address for development. Nodaro compares them byte for byte, and wildcards are not supported.
Allowed originsNoUp to 5 bare origins, without a path, a query or a fragment. Needed only if your frontend calls Nodaro from a browser (CORS).
Requested scopesYesAt least one. This is the most your app may ever ask for: users can grant less, and your app can never request more.
Homepage URL, Logo URLNoSet them on the app's page after you create it. Each is an https:// or http://localhost address. A square logo looks best.

Manage the app

  • Rotate the secret on the app's page when you lose it, or as routine security practice. The old secret stops working at once, so update the configuration of every running service right away.
  • Delete the app to revoke every access token it was given. Users who authorized it must connect again.
  • Five apps per user. MCP clients that registered themselves appear in the same list, but do not count toward the limit.
  • From code, the SDK's client.developerApps creates, updates, deletes and rotates apps. See the SDK.

Scopes

A scope is one permission your app asks for. Request only the scopes you use: users see every requested scope on the consent screen, and a short list earns their trust.

ScopeThe consent screen saysWhat it allows
workflows:readRead your workflowsList, read and export workflows.
workflows:writeCreate and modify workflowsCreate, update, delete, import and move workflows, and create sub-workflows.
workflows:executeRun workflows on your behalfRun workflows and published apps, run single generation nodes through MCP, and use the prompt wizard.
jobs:readRead job status and resultsRead jobs, their status and their results.
assets:readRead your uploaded assetsRead the gallery, uploads, favorites, app runs, characters, locations, objects and creatures.
assets:writeUpload assets to your accountUpload media, favorite assets, and create and update characters, locations and objects.
credits:readSee your credit balanceRead the credit balance and the credit transactions.
apps:readRead published appsList published apps and read their inputs.
pipelines:readRead your pipelinesRead Story-to-Video pipelines, their status and their pending approvals.
pipelines:executeRun pipelines on your behalf (this can spend your credits)Start pipelines, run their stages and branch from a stage.
pipelines:approveApprove pipeline stages on your behalfApprove stage output, and use the stage chat and the scene helpers.
presets:readRead your saved presetsRead the user's node presets and favorite presets.
workspaces:readSee the workspaces you belong toList the user's workspaces.
workspaces:writeChoose which workspace it works inChoose the workspace the app works in.
  • Some scopes gate REST routes, some gate MCP tools, and some gate both. The MCP server hides every tool whose scope the token lacks.
  • A token without the scope a route needs gets 403 insufficient_scope, with the missing scope in missingScope. See Errors.
  • The workspace scopes are never added to a token issued before organizations existed. The user must authorize your app again to grant them.
  • Running a published app needs workflows:execute to start the run and jobs:read to read its progress.

When the user clicks Connect to Nodaro, send the browser to this URL:

https://nodaro.example.com/oauth/authorize?
  client_id=app_...&
  redirect_uri=https://yourapp.com/oauth/callback&
  response_type=code&
  scope=workflows:read+workflows:execute&
  state=<random CSRF token>
ParameterRule
client_idYour app's client ID.
redirect_uriExactly one of your registered redirect URIs, byte for byte. A mismatch is refused with 400 invalid_redirect_uri.
response_typeAlways code. The consent screen refuses any other value.
scopeThe scopes you request, separated by spaces or +. They must be a subset of the app's requested scopes.
stateA random token you create for each authorization and keep in the user's session. Nodaro returns it unchanged, and you must check it.

Create state on your server:

import { randomBytes } from "node:crypto"

// In your /connect handler:
const state = randomBytes(32).toString("hex")
req.session.oauthState = state

const url = new URL("https://nodaro.example.com/oauth/authorize")
url.searchParams.set("client_id", process.env.NODARO_CLIENT_ID!)
url.searchParams.set("redirect_uri", "https://yourapp.com/oauth/callback")
url.searchParams.set("response_type", "code")
url.searchParams.set("scope", "workflows:read workflows:execute")
url.searchParams.set("state", state)
res.redirect(url.toString())
  • If the user clicks Cancel, Nodaro redirects to your redirect_uri with error=access_denied, an error_description and your state. Treat it as a normal outcome, not as a fault.
  • If the redirect URI is not registered, the consent screen shows an error page and redirects nowhere, on Cancel as on Allow.

Exchange the code for a token

After the user clicks Allow, Nodaro sends the browser to your callback:

https://yourapp.com/oauth/callback?code=ndr_code_...&state=<your state>
  1. Check state first. If it does not match the value in the user's session, stop: this is the protection against cross-site request forgery.
  2. Exchange the code on your server. Never make this call from a browser, where anyone with developer tools could read your client_secret.
import { createClient, StaticTokenAuth } from "@nodaro/sdk"

// The token endpoint is public: your client ID and secret authenticate
// the request, so the client needs no token of its own.
const client = createClient({
  baseUrl: "https://nodaro.example.com",
  auth: new StaticTokenAuth(""),
})

const tokens = await client.oauth.exchangeCode({
  client_id: process.env.NODARO_CLIENT_ID!,
  client_secret: process.env.NODARO_CLIENT_SECRET!,
  code: req.query.code as string,
  redirect_uri: "https://yourapp.com/oauth/callback",
})
// tokens.access_token: "ndr_app_..."
// tokens.scope:        the scopes the user granted, separated by spaces
// tokens.expires_in:   7776000 (seconds, that is 90 days)
// tokens.token_type:   "Bearer"
curl -X POST https://nodaro.example.com/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "client_id": "app_...",
    "client_secret": "sec_...",
    "code": "ndr_code_...",
    "redirect_uri": "https://yourapp.com/oauth/callback"
  }'

The response uses the standard OAuth field names:

{
  "access_token": "ndr_app_...",
  "token_type": "Bearer",
  "scope": "workflows:read workflows:execute",
  "expires_in": 7776000
}
  • A code works once. A second exchange of the same code returns 400 invalid_grant.
  • A code expires 10 minutes after it is issued. Exchange it as soon as your callback receives it.
  • The token endpoint accepts JSON and form-encoded bodies. Standard OAuth clients that post application/x-www-form-urlencoded work unchanged, with the client_secret_post method.

Public clients: PKCE

Mobile apps, single-page apps and CLI tools cannot keep a client_secret. They use PKCE instead. Nodaro supports the S256 method only; plain is refused with 400 invalid_request.

Create a verifier and a challenge

Before the redirect, create a random, high-entropy code_verifier. Derive code_challenge as the base64url encoding of the SHA-256 hash of the verifier.

Send the challenge with the authorize request

Add code_challenge and code_challenge_method=S256 to the authorize URL:

https://nodaro.example.com/oauth/authorize?
  client_id=app_...&
  redirect_uri=https://yourapp.com/oauth/callback&
  response_type=code&
  scope=workflows:read+workflows:execute&
  state=<random CSRF token>&
  code_challenge=<base64url SHA-256 of the verifier>&
  code_challenge_method=S256

Send the verifier with the token exchange

Send code_verifier instead of client_secret:

curl -X POST https://nodaro.example.com/v1/oauth/token \
  -H "Content-Type: application/json" \
  -d '{
    "grant_type": "authorization_code",
    "client_id": "app_...",
    "code": "ndr_code_...",
    "redirect_uri": "https://yourapp.com/oauth/callback",
    "code_verifier": "<the original verifier>"
  }'

A confidential client may send both a secret and a PKCE verifier. Nodaro checks each one that is present.

Call the API with the token

Create one client per user with that user's access token:

import { createClient, StaticTokenAuth } from "@nodaro/sdk"

const userClient = createClient({
  baseUrl: "https://nodaro.example.com",
  auth: new StaticTokenAuth(tokens.access_token),
})

// Every call acts as the user who authorized your app, within the granted scopes.
const projects = await userClient.projects.list()
const workflows = await userClient.workflows.list(projects.data[0].id)
const run = await userClient.workflows.run(workflows.data[0].id)

Without the SDK, send the token in the Authorization: Bearer header of each REST call. The endpoints are in the REST API reference.

When a token expires

An access token lasts 90 days. Nodaro issues no refresh tokens: there is one token type, one place to store it and one expiry rule, at the cost of a consent screen every 90 days.

  • After the token expires or is revoked, API calls return 401. Send the user to /oauth/authorize again.
  • When the user authorizes your app again, Nodaro updates their authorization and issues a new token. Earlier tokens stay valid until they expire or are revoked, so keep track of which tokens are in use.
  • To add a scope, send the user through the authorize URL with the broader scope. The existing authorization is widened.

Store tokens safely

  • Keep tokens on your server. Never put an access token in localStorage, sessionStorage or a cookie that JavaScript can read.
  • Encrypt tokens at rest if your platform supports it. Nodaro keeps only a SHA-256 hash of each token, so a leak of your own database is the only way a token can escape.
  • Never share a token between users. Each token belongs to one Nodaro user; using it in another user's context is an authorization bug.

Revoke a token

Revoke a token when the user signs out of your app, deletes their account on your platform, or clicks Disconnect Nodaro in your app's settings.

await client.oauth.revoke(tokens.access_token)
// { success: true }
curl -X POST https://nodaro.example.com/v1/oauth/revoke \
  -H "Content-Type: application/json" \
  -d '{ "token": "ndr_app_..." }'

The revoke endpoint always answers 200, even for a token that does not exist, so nobody can use it to test whether a guessed token is valid. After a revocation, calls with the token return 401.

Errors

The token endpoint answers with standard OAuth errors:

StatusErrorWhenWhat to do
400invalid_requestThe body is malformed or a field is missing.Fix the request.
401invalid_client (Unknown client)The client_id matches no registered app.Check the client ID in your configuration.
401invalid_client (Bad client_secret)The secret is wrong, often an old one after a rotation.Load the current secret and restart your service.
400invalid_grantThe code is older than 10 minutes, was already used, or the redirect_uri differs from the one on the authorize request.Send the user through consent again, with identical URIs.

The authorize step answers the consent screen, and never redirects with these errors:

StatusCodeWhenWhat to do
400invalid_redirect_uriThe redirect_uri is not registered for the app.Add the URI on the app's page.
400invalid_scopeA requested scope is not in the app's requested scopes.Add the scope to the app first.
404invalid_clientThe client_id is unknown, or the app is suspended.Check the client ID and the app's status.

API calls with the access token can fail with:

StatusCodeWhenWhat to do
401unauthorizedThe token expired, was revoked or is malformed.Send the user through consent again.
403insufficient_scopeThe token lacks the scope the route needs. The body names it in missingScope.Send the user through consent with the broader scope.

The SDK raises the 403 case as a ForbiddenError with a typed missingScope, so you can offer a one-click re-consent:

import { ForbiddenError } from "@nodaro/sdk"

try {
  await userClient.workflows.run(workflowId)
} catch (err) {
  if (err instanceof ForbiddenError && err.missingScope) {
    // Send the user back to /oauth/authorize with the broader scope list.
    redirectToConsent({ scopes: [...currentScopes, err.missingScope] })
    return
  }
  throw err
}

Security checklist

  • HTTPS everywhere. The Nodaro instance, your app and every redirect URI use https://. http://localhost is for local development only.
  • Check state on every callback. Create it per authorization and keep it in the user's session.
  • Keep client_secret on your server. Never bundle it into a browser app, log it or echo it in an error message.
  • Rotate the secret at least once a year, and at once if you suspect a leak.
  • Register only your own redirect URIs. Register only the addresses you actually use.
  • Request the smallest set of scopes. Add a scope when you build the feature that needs it.
  • Revoke on sign-out. Call the revoke endpoint when a user signs out of your app, so their token cannot be replayed.
  • Handle missingScope. Offer the user a re-consent, not a generic "permission denied" page.

Test the flow locally

The quickest test loop needs a Nodaro instance with Developer Apps, such as Nodaro Cloud or a Business edition install, and a small callback server on your machine.

Register a test app

Create an app with the redirect URI http://localhost:8080/cb and the scopes workflows:read, workflows:execute and jobs:read.

Run a callback server on port 8080

import express from "express"
import { createClient, StaticTokenAuth } from "@nodaro/sdk"

const NODARO_URL = "https://app.nodaro.ai" // or your Business edition install

const app = express()

app.get("/cb", async (req, res) => {
  const client = createClient({ baseUrl: NODARO_URL, auth: new StaticTokenAuth("") })
  const tokens = await client.oauth.exchangeCode({
    client_id: process.env.NODARO_CLIENT_ID!,
    client_secret: process.env.NODARO_CLIENT_SECRET!,
    code: req.query.code as string,
    redirect_uri: "http://localhost:8080/cb",
  })
  res.json(tokens)
})

app.listen(8080)

Open the authorize URL and click Allow

Open /oauth/authorize?client_id=app_...&redirect_uri=http://localhost:8080/cb&response_type=code&scope=workflows:read+workflows:execute&state=test123 on the instance. After you click Allow, the callback server shows the token JSON.

Call a real route

Use the token on a route such as GET /v1/projects/<id>/workflows, and check that it answers 200 with data.

Discovery and dynamic registration for MCP clients

MCP clients, such as Claude, ChatGPT and Cursor, find the OAuth endpoints on their own and can register themselves at runtime, without a person visiting Developer Apps. See Connect a client for the user side.

Discovery documents

EndpointStandardPurpose
GET /.well-known/oauth-authorization-serverRFC 8414Where to authorize, get tokens, register and revoke.
GET /.well-known/oauth-protected-resourceRFC 9728Binds the MCP resource, https://mcp.nodaro.ai/mcp, to its authorization server.

Each document is also served with a /mcp suffix, because some strict clients try that form first. All four resolve on both the Nodaro host and the MCP host. The issuer is the instance's PUBLIC_URL, https://app.nodaro.ai on Nodaro Cloud.

The authorization server metadata advertises:

  • the authorization, token, registration and revocation endpoints;
  • the code response type and the authorization_code grant type;
  • PKCE with the S256 method only;
  • the client_secret_post authentication method at the token endpoint;
  • every scope in scopes_supported.

Dynamic client registration

A client registers itself with POST /v1/oauth/register (RFC 7591) and receives a client_id that starts with ndr_dcr_, plus a client_secret. The endpoint accepts 10 requests per minute per IP address. The operator of the instance sets MCP_DYNAMIC_REGISTRATION:

ModeBehavior
allowlist (default)Only client names on MCP_DCR_ALLOWLIST may register. Others get 403 client_not_allowed.
openAny client may register, up to 5 unused registrations per client name and redirect URIs in 24 hours (429 too_many_open_registrations).
offRegistration is disabled (403 dcr_disabled). The operator hands out a fixed client ID and secret instead.

A client that registered itself chose its own name, so the consent screen warns the user that Nodaro did not verify it. The scopes it declares are informational: the user's consent is the real gate, and it may request any valid scope. See MCP on a self-hosted install for the operator settings.

The plugin for Figma on a self-hosted install

The Nodaro plugin for Figma runs inside Figma and cannot receive a redirect. It connects with a device-style handshake instead: the plugin shows the user a short code, the user approves the plugin on the ordinary consent screen and types that code, and the plugin then receives its token. The token is an ordinary developer-app token with the scopes jobs:read, assets:read, assets:write and credits:read, revocable like any other.

To let the plugin connect to your own install:

  1. Register a developer app, in Developer Apps or with POST /v1/developer-apps. Add <PUBLIC_URL>/v1/oauth/plugin/callback to its redirect URIs, and request the four scopes above.
  2. Put the app's client ID in FIGMA_PLUGIN_OAUTH_CLIENT_ID on the server.

Without that setting, every plugin connect route answers 503 plugin_connect_not_configured. If the app lacks the callback URI or a scope, the routes answer 503 plugin_connect_misconfigured, and the server log names what is missing.

Frequently asked questions

Last updated on

On this page