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 building | Use | Token format |
|---|---|---|
| A script, cron job, CI job or backend that uses your own account | A personal API token | ndr_ followed by 64 hex characters |
| A hosted product whose users have their own Nodaro accounts | An OAuth app | ndr_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
- The user clicks Connect to Nodaro on your site.
- Your site sends the browser to Nodaro's
/oauth/authorizepage with yourclient_id,redirect_uri,scopeandstate. - 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.
- The user clicks Allow, and Nodaro creates a one-time authorization code.
- The browser returns to your
redirect_uriwith?code=...&state=.... - Your server exchanges the code for an access token at
POST /v1/oauth/token, with yourclient_idandclient_secret. - 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.
| Field | Required | Rules |
|---|---|---|
| Name | Yes | 1 to 100 characters. Shown on the consent screen. |
| Description | No | Up to 500 characters. Shown under the name on the consent screen. |
| Redirect URIs | Yes | 1 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 origins | No | Up to 5 bare origins, without a path, a query or a fragment. Needed only if your frontend calls Nodaro from a browser (CORS). |
| Requested scopes | Yes | At 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 URL | No | Set 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.developerAppscreates, 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.
| Scope | The consent screen says | What it allows |
|---|---|---|
workflows:read | Read your workflows | List, read and export workflows. |
workflows:write | Create and modify workflows | Create, update, delete, import and move workflows, and create sub-workflows. |
workflows:execute | Run workflows on your behalf | Run workflows and published apps, run single generation nodes through MCP, and use the prompt wizard. |
jobs:read | Read job status and results | Read jobs, their status and their results. |
assets:read | Read your uploaded assets | Read the gallery, uploads, favorites, app runs, characters, locations, objects and creatures. |
assets:write | Upload assets to your account | Upload media, favorite assets, and create and update characters, locations and objects. |
credits:read | See your credit balance | Read the credit balance and the credit transactions. |
apps:read | Read published apps | List published apps and read their inputs. |
pipelines:read | Read your pipelines | Read Story-to-Video pipelines, their status and their pending approvals. |
pipelines:execute | Run pipelines on your behalf (this can spend your credits) | Start pipelines, run their stages and branch from a stage. |
pipelines:approve | Approve pipeline stages on your behalf | Approve stage output, and use the stage chat and the scene helpers. |
presets:read | Read your saved presets | Read the user's node presets and favorite presets. |
workspaces:read | See the workspaces you belong to | List the user's workspaces. |
workspaces:write | Choose which workspace it works in | Choose 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 inmissingScope. 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:executeto start the run andjobs:readto read its progress.
Send the user to the consent screen
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>| Parameter | Rule |
|---|---|
client_id | Your app's client ID. |
redirect_uri | Exactly one of your registered redirect URIs, byte for byte. A mismatch is refused with 400 invalid_redirect_uri. |
response_type | Always code. The consent screen refuses any other value. |
scope | The scopes you request, separated by spaces or +. They must be a subset of the app's requested scopes. |
state | A 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_uriwitherror=access_denied, anerror_descriptionand yourstate. 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>- Check
statefirst. If it does not match the value in the user's session, stop: this is the protection against cross-site request forgery. - 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-urlencodedwork unchanged, with theclient_secret_postmethod.
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=S256Send 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/authorizeagain. - 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,sessionStorageor 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:
| Status | Error | When | What to do |
|---|---|---|---|
400 | invalid_request | The body is malformed or a field is missing. | Fix the request. |
401 | invalid_client (Unknown client) | The client_id matches no registered app. | Check the client ID in your configuration. |
401 | invalid_client (Bad client_secret) | The secret is wrong, often an old one after a rotation. | Load the current secret and restart your service. |
400 | invalid_grant | The 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:
| Status | Code | When | What to do |
|---|---|---|---|
400 | invalid_redirect_uri | The redirect_uri is not registered for the app. | Add the URI on the app's page. |
400 | invalid_scope | A requested scope is not in the app's requested scopes. | Add the scope to the app first. |
404 | invalid_client | The 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:
| Status | Code | When | What to do |
|---|---|---|---|
401 | unauthorized | The token expired, was revoked or is malformed. | Send the user through consent again. |
403 | insufficient_scope | The 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://localhostis for local development only. - Check
stateon every callback. Create it per authorization and keep it in the user's session. - Keep
client_secreton 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
| Endpoint | Standard | Purpose |
|---|---|---|
GET /.well-known/oauth-authorization-server | RFC 8414 | Where to authorize, get tokens, register and revoke. |
GET /.well-known/oauth-protected-resource | RFC 9728 | Binds 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
coderesponse type and theauthorization_codegrant type; - PKCE with the
S256method only; - the
client_secret_postauthentication 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:
| Mode | Behavior |
|---|---|
allowlist (default) | Only client names on MCP_DCR_ALLOWLIST may register. Others get 403 client_not_allowed. |
open | Any client may register, up to 5 unused registrations per client name and redirect URIs in 24 hours (429 too_many_open_registrations). |
off | Registration 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:
- Register a developer app, in Developer Apps or with
POST /v1/developer-apps. Add<PUBLIC_URL>/v1/oauth/plugin/callbackto its redirect URIs, and request the four scopes above. - Put the app's client ID in
FIGMA_PLUGIN_OAUTH_CLIENT_IDon 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
Related
Authentication
TypeScript SDK
Connect a client
Agent skills
Embed a MiniApp
Last updated on
Examples
Ready-to-use Nodaro CLI recipes to run a workflow nightly with cron, gate CI steps on a run, script image generation, caption a video and improve a prompt.
External sign-in (SSO)
Let a trusted identity provider sign users in to a Nodaro install with a signed JWT assertion or the OIDC and SAML options, and control how accounts are linked.