An isomorphic JavaScript client for Faable Auth.
π Full documentation at faable.com/docs
- OAuth social connections (Google, GitHub, β¦) with PKCE and implicit flows
- Username + password login
- Passwordless: email magic link and OTP code
- Automatic token refresh with cross-tab synchronization via
BroadcastChannel - Pluggable storage adapters (
localStorage, cookies, or custom) - Server-side session helpers for Next.js
npm install @faable/auth-jsRequires Node.js >=22.8 for development. The published bundle runs in any
modern browser and in Node/SSR environments.
import { createClient } from '@faable/auth-js'
export const auth = createClient({
domain: '<faableauth_domain>',
clientId: '<client_id>',
redirectUri: window.location.origin
})
// Trigger a social login
await auth.signInWithOauthConnection({ connection: 'google' })createClient(config) accepts:
| Option | Type | Description |
|---|---|---|
domain |
string |
Required. Your Faable Auth tenant domain. The protocol is optional β tenant.auth.faable.link and https://tenant.auth.faable.link are equivalent. |
clientId |
string |
Required. Application client ID. |
redirectUri |
string |
Default callback URL. Falls back to window.location.origin. |
scope |
string |
Space-separated scopes. Defaults to openid profile email. |
storage |
SupportedStorage |
Custom storage adapter. Defaults to localStorage. |
storageKey |
string |
Prefix for the storage key. Final key is ${storageKey}-${clientId}. |
cookieOptions |
CookieOptions |
When set, switches storage to the cookie adapter. |
lock |
LockFunc |
Custom locking primitive for concurrent refreshes. |
debug |
boolean |
Enables verbose logging. |
// Use the default connection configured on the tenant
await auth.signInWithOauthConnection({})
// Or pick a specific provider (by name or connection_id)
await auth.signInWithOauthConnection({
connection_id: 'conn_01HXβ¦', // preferred when known; falls back to `connection` for legacy tenants
redirectTo: 'https://app.example.com/callback',
scopes: 'openid profile email',
queryParams: { prompt: 'select_account' }
})In browsers the SDK uses the PKCE flow by default and exchanges the code for a
session on the callback page. The first call to createClient automatically
processes the URL when the user lands back on the redirect target.
On the redirect success path the returned promise does not resolve β the
browser is already navigating away, so a loading state you bind to the await
stays on until the page unloads instead of flashing back to idle. Do not
re-enable UI after the await on this path.
To control the navigation yourself (e.g. custom timing, or a non-redirecting
runtime), pass skipBrowserRedirect: true. The call then resolves with the
authorization URL and leaves the navigation to you:
const { data, error } = await auth.signInWithOauthConnection({
connection: 'google',
skipBrowserRedirect: true
})
if (error) throw error
window.location.assign(data.url)await auth.signInWithUsernamePassword({
username: 'user@example.com',
password: 'β’β’β’β’β’β’β’β’',
redirectTo: 'https://app.example.com/callback'
})// Step 1 β request a code or link
await auth.signInWithPasswordless({
email: 'user@example.com',
type: 'code' // or "link"
})
// Step 2 β complete the login with the OTP the user received
const { data, error } = await auth.signInWithOtp({
username: 'user@example.com',
otp: '123456'
})await auth.changePassword({ email: 'user@example.com' })By default signOut() (global scope, in a browser) navigates the page to the
auth server's /logout to clear the SSO cookie, then returns to returnTo if
you pass one. This matters: the SSO cookie lives on the auth domain, so a
cross-origin fetch from your app can neither send nor clear it. Without the
navigation the SSO session survives and the next signInWith⦠silently re-logs
the previous user, ignoring the requested connection.
await auth.signOut() // clears local + redirects to /logout to clear the SSO cookie
await auth.signOut({ returnTo: 'https://app.example.com/bye' }) // + landing page
await auth.signOut({ scope: 'local' }) // only this device's storage, no redirect
await auth.signOut({ redirect: false }) // legacy: local + best-effort fetch, no navreturnTo maps to the OIDC post_logout_redirect_uri and must be registered
as a logout URL on the client, or the server responds 400.
On the redirect path the returned promise does not resolve (the browser is
navigating away) β do not re-enable UI after the await. To drive the
navigation yourself, build the URL with getLogoutUrl:
window.location.assign(
auth.getLogoutUrl({ returnTo: 'https://app.example.com' })
)The client has one error contract, applied uniformly: every asynchronous
method resolves with { data, error } and never throws for an expected
failure (bad credentials, wrong OTP, missing session, network errorβ¦). On
success error is null; on failure data is null and error is an
AuthError. Always check error before reading data:
const { data, error } = await auth.signInWithOtp({ username, otp })
if (error) {
showError(error.message)
return
}
useSession(data.session)The only thing that throws is createClient itself, and only for a
misconfiguration (missing domain / clientId) β a programming error you fix
once, not a runtime condition to catch.
This applies to signInWithOauthConnection, signInWithUsernamePassword,
signUp, signInWithOtp, signInWithPasswordless, changePassword,
changeEmail, signOut, getSession, setSession, refreshSession,
initialize and handleRedirectCallback. Their return types (AuthResult<T>,
AuthResponse, OAuthResponse) are all variants of the same shape.
If your code path would rather let errors propagate (a server handler, a
try/catch, a wrapper that normalizes everything to throws), wrap the call in
unwrap instead of hand-writing if (error) throw error:
import { unwrap } from '@faable/auth-js'
// returns data on success, throws the AuthError on failure
const { session } = unwrap(await auth.signInWithOtp({ username, otp }))// Get the current session (refreshes if needed)
const {
data: { session }
} = await auth.getSession()
// Subscribe to auth events
const {
data: { subscription }
} = auth.onAuthStateChange((event, session) => {
// event: INITIAL_SESSION | SIGNED_IN | SIGNED_OUT | TOKEN_REFRESHED | PASSWORD_RECOVERY | USER_UPDATED
})
// Stop listening
subscription.unsubscribe()
// Force a refresh
await auth.refreshSession()Auth events are broadcast across tabs using BroadcastChannel, so a sign-in or
sign-out in one tab is reflected in every other tab using the same storageKey.
Refresh tokens are sensitive: anyone who reads them can impersonate the user until the token is revoked. The storage you pick decides where they live:
localStorage(default) β simple and supports cross-tab sync viaBroadcastChannel, but any script running on the same origin can read it. A single XSS lets an attacker exfiltrate the refresh token. Acceptable for low-risk apps and prototypes; not recommended when the surface has third-party scripts, user-generated HTML, or strict compliance requirements.- Cookies β required for SSR (server reads them on every request) and the
only adapter that lets you scope storage with
Secure,SameSite, andDomain. Note that this library writes cookies from JavaScript, so they cannot be markedHttpOnly; an XSS can still read them, but cookies make CSRF and same-site policies enforceable in a waylocalStoragedoes not. - Custom adapter β use for in-memory storage (tokens lost on reload, safest against XSS), Web Workers, or platform-specific keychains.
If your app is exposed to untrusted content, prefer cookies with Secure: true
and SameSite: "Lax" (or "Strict"), and treat XSS prevention (CSP, escaping,
framework guarantees) as a hard requirement regardless of which adapter you
pick.
Used automatically in browsers. No configuration required.
Useful for SSR setups where the server must read the session from the request.
import { createClient } from '@faable/auth-js'
export const auth = createClient({
domain: '<faableauth_domain>',
clientId: '<client_id>',
storage: 'cookie'
})That's it. The adapter sets sensible defaults: Path=/, SameSite=Lax, auto
Secure on HTTPS, and a 30-day Max-Age so users stay signed in across browser
restarts.
Use cookieOptions only when you need to override something β e.g. share the
session across subdomains:
createClient({
domain: '<faableauth_domain>',
clientId: '<client_id>',
storage: 'cookie',
cookieOptions: { domain: '.example.com' }
})Provide any object that implements getItem, setItem, and removeItem (sync
or async). Set isServer: true if values may come from an untrusted source such
as request cookies.
const memoryStorage = {
store: new Map<string, string>(),
getItem: (k: string) => memoryStorage.store.get(k) ?? null,
setItem: (k: string, v: string) => void memoryStorage.store.set(k, v),
removeItem: (k: string) => void memoryStorage.store.delete(k)
}
createClient({ domain, clientId, storage: memoryStorage })Use cookie storage on the client, then read the session from next/headers on
the server:
// app/page.tsx
import { cookies } from 'next/headers'
import { getSessionFromCookies } from '@faable/auth-js'
export default async function Page() {
const session = getSessionFromCookies(cookies(), { clientId: '<client_id>' })
if (!session) return <SignIn />
return <Dashboard user={session.user} />
}Pass the same clientId you used in createClient. If you also passed a custom
storageKey to createClient, mirror it here as { clientId, storageKey } so
the helper looks at the same cookie.
For the full guides, API reference, and dashboard setup walkthroughs visit faable.com/docs.
See LICENSE.md.