LendWorksLendWorksDocs

OAuth 2.0 Authorization

Implement the Authorization Code flow with PKCE to authenticate users and access org-scoped data.

Overview

LendWorks uses OAuth 2.0 Authorization Code + PKCE for app authentication. This flow ensures that users explicitly grant your app access to their organization's data with specific scopes.

PKCE (Proof Key for Code Exchange) is required for all apps — it prevents authorization code interception attacks, even for server-side apps.

Authorization Flow

┌──────────┐     1. Install/Connect      ┌──────────────┐
│          │ ──────────────────────────→  │              │
│  User's  │     2. Consent Screen       │  LendWorks   │
│ Browser  │ ←──────────────────────────  │  OAuth       │
│          │     3. Approve              │  Server      │
│          │ ──────────────────────────→  │              │
│          │     4. Redirect + code      │              │
│          │ ←──────────────────────────  │              │
└──────────┘                             └──────────────┘
     │                                         │
     │  5. Send code to your server             │
     ▼                                         │
┌──────────┐     6. Exchange code + verifier   │
│  Your    │ ──────────────────────────→       │
│  App     │     7. Access + Refresh tokens    │
│  Server  │ ←──────────────────────────       │
│          │     8. API calls with token        │
│          │ ──────────────────────────→       │
└──────────┘                             └──────────────┘

Step 1: Generate PKCE Challenge

Before redirecting the user, generate a code verifier and challenge:

import crypto from 'node:crypto'

// Generate a random code verifier (43-128 characters)
const codeVerifier = crypto.randomBytes(32).toString('base64url')

// Create the code challenge (SHA-256 hash of verifier)
const codeChallenge = crypto
  .createHash('sha256')
  .update(codeVerifier)
  .digest('base64url')

// Store codeVerifier in session — you'll need it later
session.codeVerifier = codeVerifier

Step 2: Redirect to Authorization

Redirect the user to the LendWorks authorization endpoint:

GET https://auth.lend.works/oauth/authorize
ParameterRequiredDescription
client_idYesYour app's client ID
redirect_uriYesMust match one of your registered redirect URIs
response_typeYesMust be code
scopeYesSpace-separated list of scopes
stateYesRandom string to prevent CSRF — verify on callback
code_challengeYesBase64url-encoded SHA-256 hash of code verifier
code_challenge_methodYesMust be S256

Example

const state = crypto.randomBytes(16).toString('hex')
session.oauthState = state

const params = new URLSearchParams({
  client_id: 'lw_app_xxxxxxxxxxxx',
  redirect_uri: 'https://yourapp.com/callback',
  response_type: 'code',
  scope: 'leads:read leads:write webhooks:manage',
  state,
  code_challenge: codeChallenge,
  code_challenge_method: 'S256',
})

// Redirect user
res.redirect(`https://auth.lend.works/oauth/authorize?${params}`)

Step 3: Handle the Callback

After the user approves, LendWorks redirects back to your redirect_uri with a code and state parameter:

GET https://yourapp.com/callback?code=abc123&state=xyz789

Verify the state matches what you stored:

app.get('/callback', async (req, res) => {
  const { code, state } = req.query

  // Verify state to prevent CSRF
  if (state !== session.oauthState) {
    return res.status(400).send('Invalid state parameter')
  }

  // Exchange code for tokens (see Step 4)
})

If the user denies access, the callback receives error and error_description parameters instead of code.

Step 4: Exchange Code for Tokens

Exchange the authorization code for access and refresh tokens:

POST https://auth.lend.works/oauth/token
Content-Type: application/x-www-form-urlencoded
const tokenResponse = await fetch('https://auth.lend.works/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'authorization_code',
    client_id: 'lw_app_xxxxxxxxxxxx',
    client_secret: process.env.LENDWORKS_CLIENT_SECRET,
    code,
    redirect_uri: 'https://yourapp.com/callback',
    code_verifier: session.codeVerifier,
  }),
})

const tokens = await tokenResponse.json()

Successful Response

{
  "access_token": "lw_at_xxxxxxxxxxxxxxxx",
  "refresh_token": "lw_rt_xxxxxxxxxxxxxxxx",
  "token_type": "Bearer",
  "expires_in": 3600,
  "scope": "leads:read leads:write webhooks:manage",
  "org_id": "550e8400-e29b-41d4-a716-446655440000"
}

Store the refresh token securely — you'll need it to get new access tokens.

Step 5: Use the Access Token

Include the access token in API requests:

const client = new LendWorksClient({
  accessToken: tokens.access_token,
})

const { data: leads } = await client.leads.list({ limit: 10 })

Or with cURL:

curl -H "Authorization: Bearer lw_at_xxxxxxxxxxxxxxxx" \
     https://api.lend.works/v1/leads?limit=10

Token Lifecycle

TokenLifetimePurpose
Access token1 hourAuthenticate API requests
Refresh token90 daysObtain new access tokens without user interaction
Authorization code10 minutesOne-time use, exchanged for tokens

Refreshing Tokens

When the access token expires, use the refresh token to obtain a new one:

const refreshResponse = await fetch('https://auth.lend.works/oauth/token', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    grant_type: 'refresh_token',
    client_id: 'lw_app_xxxxxxxxxxxx',
    client_secret: process.env.LENDWORKS_CLIENT_SECRET,
    refresh_token: storedRefreshToken,
  }),
})

const newTokens = await refreshResponse.json()
// {
//   "access_token": "lw_at_new_token...",
//   "refresh_token": "lw_rt_new_refresh...",
//   "token_type": "Bearer",
//   "expires_in": 3600
// }

The response includes a new refresh token — always store the latest one. Previous refresh tokens are invalidated (rotation).

Revoking Tokens

Revoke tokens when a user disconnects your app or you no longer need access:

await fetch('https://auth.lend.works/oauth/revoke', {
  method: 'POST',
  headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
  body: new URLSearchParams({
    client_id: 'lw_app_xxxxxxxxxxxx',
    client_secret: process.env.LENDWORKS_CLIENT_SECRET,
    token: storedRefreshToken,
  }),
})

Revoking a refresh token also invalidates all associated access tokens.

Error Responses

ErrorDescription
invalid_requestMissing or invalid parameter
invalid_clientClient authentication failed
invalid_grantAuthorization code expired, already used, or code verifier mismatch
unauthorized_clientClient not authorized for this grant type
unsupported_grant_typeGrant type not supported
invalid_scopeRequested scope exceeds app's registered scopes

Sign in with LendWorks

Use the openid scope to implement "Sign in with LendWorks" — let users authenticate with their LendWorks identity in your application.

How It Works

  1. Your app redirects the user to LendWorks with scope=openid email
  2. User approves on the LendWorks consent screen
  3. You exchange the authorization code for an access token
  4. You call the Userinfo endpoint to get the user's identity
  5. You create or link a local account based on the returned claims

OIDC Discovery

LendWorks publishes an OpenID Connect Discovery document for automatic endpoint detection:

GET https://auth.lend.works/.well-known/openid-configuration

Most OAuth/OIDC libraries (Passport.js, NextAuth, Auth.js, Spring Security) can auto-configure from this URL.

Step-by-Step Example

import crypto from 'node:crypto'

// 1. Redirect to LendWorks authorization
const codeVerifier = crypto.randomBytes(32).toString('base64url')
const codeChallenge = crypto
  .createHash('sha256')
  .update(codeVerifier)
  .digest('base64url')

const state = crypto.randomBytes(16).toString('hex')

const params = new URLSearchParams({
  client_id: 'lw_app_xxxxxxxxxxxx',
  redirect_uri: 'https://yourapp.com/auth/callback',
  response_type: 'code',
  scope: 'openid email',
  state,
  code_challenge: codeChallenge,
  code_challenge_method: 'S256',
})

res.redirect(`https://auth.lend.works/oauth/authorize?${params}`)
// 2. Handle the callback — exchange code for tokens
app.get('/auth/callback', async (req, res) => {
  const { code, state } = req.query
  if (state !== session.oauthState) {
    return res.status(400).send('Invalid state')
  }

  const tokenRes = await fetch('https://auth.lend.works/oauth/token', {
    method: 'POST',
    headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
    body: new URLSearchParams({
      grant_type: 'authorization_code',
      client_id: process.env.LENDWORKS_CLIENT_ID,
      client_secret: process.env.LENDWORKS_CLIENT_SECRET,
      code,
      redirect_uri: 'https://yourapp.com/auth/callback',
      code_verifier: session.codeVerifier,
    }),
  })

  const tokens = await tokenRes.json()

  // 3. Fetch user identity from the userinfo endpoint
  const userinfoRes = await fetch('https://auth.lend.works/oauth/userinfo', {
    headers: { Authorization: `Bearer ${tokens.access_token}` },
  })

  const user = await userinfoRes.json()
  // {
  //   "sub": "550e8400-e29b-41d4-a716-446655440000",
  //   "name": "Jane Smith",
  //   "given_name": "Jane",
  //   "family_name": "Smith",
  //   "email": "jane@acmebrokerage.com",
  //   "email_verified": true,
  //   "picture": "https://storage.lend.works/avatars/abc123.jpg",
  //   "updated_at": 1711584000,
  //   "https://lend.works/org_id": "org_uuid_here",
  //   "https://lend.works/org_name": "Acme Brokerage",
  //   "https://lend.works/org_slug": "acme-brokerage",
  //   "https://lend.works/role": "admin"
  // }

  // 4. Create or link a local account
  const localUser = await db.upsertUser({
    externalId: user.sub,
    email: user.email,
    name: user.name,
    orgId: user['https://lend.works/org_id'],
  })

  session.userId = localUser.id
  res.redirect('/dashboard')
})

Identity Scopes

ScopeClaims Returned
openidsub, name, given_name, family_name, picture, updated_at, plus LendWorks org claims
emailemail, email_verified (requires openid)
profile:readOrganization profile information via the REST API

Userinfo Endpoint

GET https://auth.lend.works/oauth/userinfo
Authorization: Bearer {access_token}

Also supports POST with the same Authorization header per OIDC spec.

Successful Response (HTTP 200):

{
  "sub": "550e8400-e29b-41d4-a716-446655440000",
  "name": "Jane Smith",
  "given_name": "Jane",
  "family_name": "Smith",
  "picture": "https://storage.lend.works/avatars/abc123.jpg",
  "updated_at": 1711584000,
  "email": "jane@acmebrokerage.com",
  "email_verified": true,
  "https://lend.works/org_id": "org_uuid_here",
  "https://lend.works/org_name": "Acme Brokerage",
  "https://lend.works/org_slug": "acme-brokerage",
  "https://lend.works/role": "admin"
}

Error Responses:

HTTP StatusErrorWhen
401invalid_tokenMissing, expired, or revoked Bearer token
403insufficient_scopeToken does not include the openid scope

All error responses include a WWW-Authenticate header per RFC 6750 § 3.

Security Best Practices

  • Always use PKCE — It is mandatory and prevents code interception attacks
  • Validate the state parameter — Prevents CSRF attacks on the callback
  • Store secrets server-side — Never expose client secrets or refresh tokens in client-side code
  • Use short-lived access tokens — Rely on refresh tokens for long-lived access
  • Rotate refresh tokens — Always store the latest refresh token from each response
  • Revoke on disconnect — Clean up tokens when a user uninstalls your app
  • Verify the sub claim — Use sub (not email) as the stable user identifier — emails can change
  • Check email_verified — Only trust email addresses with email_verified: true