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 = codeVerifierStep 2: Redirect to Authorization
Redirect the user to the LendWorks authorization endpoint:
GET https://auth.lend.works/oauth/authorize| Parameter | Required | Description |
|---|---|---|
client_id | Yes | Your app's client ID |
redirect_uri | Yes | Must match one of your registered redirect URIs |
response_type | Yes | Must be code |
scope | Yes | Space-separated list of scopes |
state | Yes | Random string to prevent CSRF — verify on callback |
code_challenge | Yes | Base64url-encoded SHA-256 hash of code verifier |
code_challenge_method | Yes | Must 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=xyz789Verify 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-urlencodedconst 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=10Token Lifecycle
| Token | Lifetime | Purpose |
|---|---|---|
| Access token | 1 hour | Authenticate API requests |
| Refresh token | 90 days | Obtain new access tokens without user interaction |
| Authorization code | 10 minutes | One-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
| Error | Description |
|---|---|
invalid_request | Missing or invalid parameter |
invalid_client | Client authentication failed |
invalid_grant | Authorization code expired, already used, or code verifier mismatch |
unauthorized_client | Client not authorized for this grant type |
unsupported_grant_type | Grant type not supported |
invalid_scope | Requested 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
- Your app redirects the user to LendWorks with
scope=openid email - User approves on the LendWorks consent screen
- You exchange the authorization code for an access token
- You call the Userinfo endpoint to get the user's identity
- 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-configurationMost 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
| Scope | Claims Returned |
|---|---|
openid | sub, name, given_name, family_name, picture, updated_at, plus LendWorks org claims |
email | email, email_verified (requires openid) |
profile:read | Organization 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 Status | Error | When |
|---|---|---|
| 401 | invalid_token | Missing, expired, or revoked Bearer token |
| 403 | insufficient_scope | Token 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
stateparameter — 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
subclaim — Usesub(notemail) as the stable user identifier — emails can change - Check
email_verified— Only trust email addresses withemail_verified: true