A reference implementation showing how to build a web app that lets end-users connect their own OAuth credentials to n8n workflows, using the Dynamic Credentials feature.
Users log in via an identity provider (Okta in this example), see a list of workflows, connect the required credentials (e.g. Google Sheets), and run workflows — all from a single dashboard.
Browser Backend (Express) n8n
| | |
| 1. GET /api/workflows | |
|<----- workflow list ---------| |
| | |
| 2. GET /api/workflows/:id/execution-status |
| (user token) |-- forward + API token ----->|
|<----- credentials status ----|<-- readyToExecute, creds ---|
| | (URLs stripped, |
| | canAuthorize/canRevoke) |
| | |
| 3. POST /api/workflows/:wfId/credentials/:credId/authorize
| (user token) |-- fetch execution-status -->|
| |-- POST authorizationUrl --->|
|<----- OAuth consent URL -----|<-- Google OAuth URL --------|
| | |
| (user completes OAuth | |
| in popup window) | |
| | |
| 4. POST /api/workflows/:id/run |
| (user token) |-- POST webhook endpoint --->|
|<----- result ----------------|<-- execution result --------|
Key design decisions:
- The n8n API token (
N8N_DYNAMIC_CREDENTIALS_ENDPOINT_AUTH_TOKEN) never leaves the backend. The frontend only sends the user's identity token; the backend injects the API token when calling n8n. - n8n internal URLs are never exposed to the frontend. The backend strips
authorizationUrlandrevokeUrlfrom execution-status responses and replaces them with boolean flags (canAuthorize,canRevoke). Credential operations go through explicit backend endpoints. - Workflow webhook paths are server-side only. The frontend calls
/api/workflows/:id/run; the backend resolves the actual webhook URL.
src/
backend/ # Express server
index.ts # App setup, static file serving in production
routes.ts # API routes + workflow definitions
tsconfig.json
frontend/ # React (Vite) SPA
index.html
vite.config.ts
main.tsx # Entry point + router
App.tsx # Route definitions
types.ts # Shared TypeScript interfaces
tsconfig.json
auth/ # Auth provider abstraction (Okta by default)
AuthProvider.tsx # Provider-specific context wrapper
AuthCallback.tsx # Provider-specific login callback
useAuth.ts # Provider-agnostic hook used by all components
pages/
Home.tsx # Login page
Workflows.tsx # Main dashboard — data fetching + state
components/
Navbar.tsx # Top nav with user info + logout
WorkflowCard.tsx # Single workflow card UI
CredentialPill.tsx # Credential status badge with actions
ProtectedRoute.tsx # Redirect to login if not authenticated
styles/
index.css # Global styles + CSS variables
App.css # Workflow dashboard styles
Home.css # Login page styles
All Okta-specific code is isolated in src/frontend/auth/. The rest of the app only imports useAuth(), which exposes a provider-agnostic interface.
- Node.js >= 20
- pnpm (or npm/yarn)
- An n8n instance with dynamic credentials enabled
- An Okta application (or adapt
main.tsxto your identity provider)
- Clone the repo and install dependencies:
pnpm install- Copy the example env file and fill in your values:
cp .env.example .env| Variable | Description |
|---|---|
N8N_URL |
URL of your n8n instance (e.g. http://localhost:5678) |
N8N_DYNAMIC_CREDENTIALS_ENDPOINT_AUTH_TOKEN |
Token to authenticate with n8n's dynamic credentials endpoint (sent as X-Authorization header) |
VITE_OKTA_CLIENT_ID |
Your Okta application's client ID |
VITE_OKTA_DOMAIN |
Your Okta domain (e.g. dev-123456.okta.com) |
VITE_OKTA_ISSUER |
Your Okta authorization server issuer URL |
- Add your workflows in
src/backend/routes.ts:
const workflows = [
{
id: "your-n8n-workflow-id",
name: "Spreadsheet Generator",
description: "Generates a new spreadsheet in your Google Drive",
webhookPath: "your-webhook-uuid",
},
];The id is the n8n workflow ID. The webhookPath is the path portion of the workflow's webhook trigger URL.
pnpm devThis starts both:
- Vite dev server on
http://localhost:5173(with HMR) - Express backend on
http://localhost:3000(with auto-reload via--watch)
Vite proxies /api requests to the Express backend automatically.
pnpm build # TypeScript check + Vite build
pnpm start # Express serves the built frontend + APIThe Express server serves the static frontend from dist/ and handles all /api routes. Single process, no separate web server needed.
docker build -t dynamic-credentials-example .
docker run -p 3000:3000 --env-file .env dynamic-credentials-exampleIf you're building your own implementation, these are the files that matter most:
| File | Why it matters |
|---|---|
src/backend/routes.ts |
Start here. Contains the workflow registry and all API route handlers. Shows how to call n8n's execution-status endpoint, how to strip internal URLs, and how to trigger the OAuth flow and revocation server-side. |
src/backend/index.ts |
Minimal Express setup — how the n8n API token is kept server-side, and how the app is served in production. |
src/frontend/auth/useAuth.ts |
The provider-agnostic auth hook. This is the interface your components should depend on — not the IdP SDK directly. |
src/frontend/auth/AuthProvider.tsx |
The only file with Okta-specific imports. Replace this (and its siblings) to switch identity providers. |
src/frontend/pages/Workflows.tsx |
The main dashboard. Shows the full data-fetching lifecycle: loading workflows, polling credential status, triggering authorization popups, and running workflows. |
src/frontend/components/CredentialPill.tsx |
How to represent credential state (canAuthorize / canRevoke) in the UI and wire up the OAuth popup flow. |
src/frontend/types.ts |
The TypeScript interfaces shared across frontend and backend responses. A good starting point to understand the data model. |
This example uses Okta, but the pattern works with any OpenID Connect provider. Auth is abstracted behind the useAuth() hook — you only need to change 3 files in src/frontend/auth/:
| File | What to change |
|---|---|
AuthProvider.tsx |
Replace the Okta <Security> wrapper with your provider's context (e.g. Auth0's <Auth0Provider>) |
AuthCallback.tsx |
Replace Okta's <LoginCallback> with your provider's callback handler |
useAuth.ts |
Replace the useOktaAuth() calls with your provider's hook, keeping the same return type (isAuthenticated, isPending, userName, getAccessToken, signIn, signOut) |
Then update the VITE_OKTA_* env vars in .env with your provider's config.
No changes needed in pages, components, or the backend — they all use the useAuth() hook and never reference Okta directly.