webba-id-sdk
Official SDK for OAuth 2.0 + PKCE authentication with Webba ID.
npm install webba-id-sdk
If you're using Claude Code, also run:
npx webba-id-sdk init
It installs the /webba-id skill at .claude/skills/webba-id/ (so the AI agents in your project know how the SDK actually works) and generates AuthContext.tsx + AuthCallback.tsx templates.
TL;DR — front-end SPA
import { webba } from 'webba-id-sdk'
webba.configure({
appKey: import.meta.env.VITE_WEBBA_APP_KEY,
appSecret: import.meta.env.VITE_WEBBA_APP_SECRET,
redirectUri: window.location.origin + '/auth/callback',
storagePrefix: 'myapp',
})
import { webba } from 'webba-id-sdk'
<button onClick={() => webba.login()}>Sign in with Webba ID</button>
import { useEffect, useRef } from 'react'
import { webba } from 'webba-id-sdk'
export function AuthCallback() {
const ran = useRef(false)
useEffect(() => {
if (ran.current) return
ran.current = true
webba.handleCallback().then(async (tokens) => {
const user = await webba.getUser(tokens.accessToken)
webba.saveTokens(tokens)
webba.saveUser(user)
location.href = '/'
})
}, [])
return <p>Connecting...</p>
}
That's a full working integration. For a production-quality AuthContext (token refresh, cross-tab sync, focus listeners), copy the template the init CLI installs at src/contexts/AuthContext.tsx.
Security — appSecret and VITE_*
The Webba ID /auth-token endpoint requires the app secret to exchange a code for tokens. There is no PKCE-only flow yet on the server — meaning a true public SPA cannot exchange codes without revealing the secret somewhere.
You have two choices:
Recommended — backend exchange
Keep your secret server-side. The frontend handles login/state/PKCE and ships the code to your backend; your backend calls Webba ID with the secret.
webba.configure({
appKey: import.meta.env.VITE_WEBBA_APP_KEY,
redirectUri: location.origin + '/auth/callback',
})
import { createWebbaID } from 'webba-id-sdk'
const server = createWebbaID({
appKey: process.env.WEBBA_APP_KEY!,
appSecret: process.env.WEBBA_APP_SECRET!,
redirectUri: 'https://example.com/auth/callback',
})
const tokens = await server.exchangeCode(code, codeVerifier)
Pragmatic — accept the secret in the bundle
If your app's threat model is fine with shipping the app secret to clients (low-stakes apps, internal tools), put it in VITE_WEBBA_APP_SECRET and call webba.handleCallback() directly.
⚠ NEVER use VITE_* for secrets you actually want kept private. Vite inlines VITE_* env vars into the production bundle — anyone hitting the page sees the value in DevTools.
Configuration
interface WebbaIDConfig {
appKey: string
appSecret?: string
redirectUri?: string
apiKey?: string
baseUrl?: string
apiUrl?: string
storagePrefix?: string
}
Auto-configure from env
import { webba } from 'webba-id-sdk'
const ok = webba.configureFromEnv()
if (!ok) {
}
API reference
Login flow
login() | Generate PKCE + state, redirect to Webba ID's /oauth/authorize |
isCallback() | True when the URL has ?code= or ?error= |
handleCallback() | All-in-one: parse URL, validate state, exchange code → AuthTokens. Idempotent under React StrictMode |
exchangeCode(code, codeVerifier?) | Lower-level: just exchange the code (server-side or advanced) |
Tokens
refreshTokens(refreshToken) | Returns new AuthTokens |
revokeToken(token) | Revoke an access or refresh token |
verifyToken(accessToken) | { valid: boolean, user_id?, email? } (never throws) |
logout(accessToken?, refreshToken?) | Revoke + clear local state |
User info
getUser(accessToken) | Convenience WebbaUser shape (id, email, name, avatar) |
getUserInfo(accessToken, includeProfile?) | Raw WebbaIDUser (with subscriptions when includeProfile=true) |
getProfile(accessToken) | Full profile + subscriptions + roles |
updateProfile(accessToken, partial) | PUT changes to the profile |
Storage helpers (browser)
saveTokens(tokens) / getStoredTokens() / clearTokens() | localStorage at ${storagePrefix}_tokens |
saveUser(user) / getStoredUser() / clearUser() | localStorage at ${storagePrefix}_user |
isTokenExpired(tokens) | Date.now() >= tokens.expiresAt |
Linked identities (client-side)
getLinkedIdentities(accessToken) | All linked OAuth providers |
hasProviderLinked(accessToken, provider) | Boolean shortcut |
getProviderIdentity(accessToken, provider) | Single identity or null |
getProviderId(accessToken, provider) | Just the provider id, or null |
hasCfxreLinked(accessToken) / getCfxreIdentity / getCfxreId | Cfx.re shortcuts |
Reverse lookup (server-side; requires appSecret)
lookupUserByProvider(provider, providerUserId) | Find a Webba user from a provider id |
lookupUsersByProvider(provider, providerUserIds[]) | Batch (max 100) |
getUserIdentitiesByUserId(userId) | All providers linked to a Webba user |
getProviderIdByUserId(userId, provider) | Provider id for a user, or null |
getCfxreIdByUserId(userId) / getDiscordIdByUserId(userId) | Shortcuts |
App access
checkAppAccess(accessToken, orgId?) | { has_access, reason, subscription, organizations } |
hasAccess(accessToken, orgId?) | Boolean shortcut |
enableAppForOrg(accessToken, orgId) / disableAppForOrg(...) | Toggle the app for an org |
Two ways to use it
Singleton — most apps
import { webba } from 'webba-id-sdk'
webba.configure({ appKey: 'wba_xxx', appSecret: 'wbs_xxx', redirectUri: '...' })
await webba.login()
Factory — multi-tenant or server-side
import { createWebbaID } from 'webba-id-sdk'
const client = createWebbaID({
appKey: process.env.WEBBA_APP_KEY!,
appSecret: process.env.WEBBA_APP_SECRET!,
redirectUri: 'https://example.com/callback',
})
const result = await client.lookupUserByProvider('discord', message.author.id)
if (result.user) {
console.log('Webba user:', result.user.user_id)
}
Storage prefix
If two Webba apps share the same origin (e.g. an admin panel and a customer app on app.example.com), set distinct prefixes so their tokens don't collide:
webba.configure({ appKey, appSecret, redirectUri, storagePrefix: 'admin' })
Murphy's Workshop uses storagePrefix: 'murphy'. The default is 'webba'.
Production-grade AuthContext
The basic example above misses cross-tab sync, proactive refresh on focus, and lock coordination. The init CLI ships a battle-tested template (~230 lines) that handles all of it. Read .claude/skills/webba-id/auth-context.md after running npx webba-id-sdk init, or check the source on the Webba ID repo.
Server-side OAuth flow (Discord bot, Express, ...)
import { createWebbaID } from 'webba-id-sdk'
const webbaId = createWebbaID({
appKey: process.env.WEBBA_APP_KEY!,
appSecret: process.env.WEBBA_APP_SECRET!,
redirectUri: 'https://example.com/auth/callback',
})
const result = await webbaId.lookupUserByProvider('discord', '1234567890')
if (result.user) {
const cfxreId = await webbaId.getCfxreIdByUserId(result.user.user_id)
}
const batch = await webbaId.lookupUsersByProvider('discord', ['111', '222', '333'])
console.log(batch.mapping)
Supported providers: discord, google, github, azure, cfxre.
Troubleshooting
Every API call returns 401
You're likely using a self-hosted Webba ID and forgot to set apiKey. The SDK sends apikey + Authorization: Bearer <apiKey> headers on every request — if the value isn't a valid Supabase anon key for your Webba ID instance, Supabase's gateway rejects every call.
"Authorization code already processed"
You called handleCallback() twice with the same code (e.g. React StrictMode double-mount, or you remounted the callback component). The SDK has internal protection against this, but check that your callback page guards with a useRef flag too:
const ran = useRef(false)
useEffect(() => {
if (ran.current) return
ran.current = true
webba.handleCallback().then(...)
}, [])
"Session expired or invalid"
PKCE data is gone. Common causes:
- More than 10 minutes between
login() and the callback.
- Browser blocks third-party cookies and localStorage was cleared (e.g. cross-origin redirect through Cfx.re OAuth).
- You used different origins (
localhost vs 127.0.0.1).
"appSecret is required"
The exchange-code call hit the server without an app secret. Either:
- Set
appSecret in webba.configure(...) (frontend; ships secret in bundle), or
- Move the exchange to your backend (recommended).
TypeScript: webba is unknown
Make sure you import from the package, not from a local file:
import { webba } from 'webba-id-sdk'
import { webba } from './webba-id-sdk'
OAuth flow diagram
1. User clicks "Sign in"
↓
2. webba.login() → generates PKCE pair + state
→ redirects to https://account.webba-creative.com/oauth/authorize
3. User authenticates on Webba ID
↓
4. Webba ID redirects to /auth/callback?code=...&state=...
↓
5. webba.handleCallback()
↓ verify state (CSRF)
↓ POST /auth-token { code, app_key, app_secret, redirect_uri, code_verifier }
↓ returns AuthTokens
6. webba.getUser(accessToken)
↓ GET /auth-userinfo?app_key=...&access_token=...&include_profile=true
↓ returns WebbaUser
7. webba.saveTokens(tokens) / webba.saveUser(user)
↓
8. Redirect to your app's home page
License
MIT — Webba Creative Technologies 2026