Skip to Content
SDK ReferenceClient SDK (browser)

Client SDK (browser)

@ghayma/sdk/client is the browser half of the Ghayma SDK: the code that signs the end-users of your application in. It handles login, registration, token management, OAuth, and profile management.

This entry authenticates with your app slug alone and never sees a project API key. For server-side administration — listing users, disabling them, setting app_metadata — use the Server SDK.

@ghayma/auth is deprecated, and keeps working as an alias for the next few months: the two imports are interchangeable, and the API on this page is identical either way. Migrate by changing @ghayma/auth to @ghayma/sdk/client. The alias re-exports whichever @ghayma/sdk is installed beside it (^1.1.0), so an app with 2FA needs @ghayma/sdk 1.3.0 or later under either name: npm i @ghayma/sdk@^1.3.0 (why).

Installation

npm install @ghayma/sdk

Quick Start

import { GhaymaAuth } from "@ghayma/sdk/client"; const auth = new GhaymaAuth({ appSlug: "my-auth-app" }); // Register a new user const session = await auth.register({ email: "[email protected]", password: "securepass123", name: "John Doe", }); // Login const { user } = await auth.login({ email: "[email protected]", password: "securepass123", }); // Get profile (auto-refreshes token if expired) const me = await auth.getUser(); // Check auth state console.log(auth.isAuthenticated()); // true // Logout await auth.logout();

Configuration

const auth = new GhaymaAuth({ appSlug: "my-auth-app", // Required — your Auth App slug baseUrl: "https://auth.ghayma.tech", // Default storage: "memory", // "memory" (default) or "localStorage" autoRefresh: true, // Auto-refresh tokens (default: true) });
OptionTypeDefaultDescription
appSlugstring—Required. Your Auth App slug
baseUrlstringhttps://auth.ghayma.techAuth service URL
storage"memory" | "localStorage""memory"Token storage strategy
autoRefreshbooleantrueAuto-refresh before expiry
serverKeystring?injected env (Node)Server-side only. Lets you forward each end user’s IP for rate limiting — see Server Key

Where appSlug comes from

On Ghayma the platform derives the auth app’s id into every site connected to it, so read it from the environment rather than hard-coding it:

  • Next.js — NEXT_PUBLIC_GHAYMA_AUTH_APP_ID, derived both as a build argument and at runtime, so next build inlines it into the browser bundle:

    const auth = new GhaymaAuth({ appSlug: process.env.NEXT_PUBLIC_GHAYMA_AUTH_APP_ID });
  • Server-side code, any framework — GHAYMA_AUTH_APP_ID.

  • Any other bundler — map GHAYMA_AUTH_APP_ID onto that bundler’s public prefix in your build config, or set the public variable yourself. See The app id in a frontend build.

Both names point at the project’s oldest auth app. In a project with several apps, name another one explicitly with its per-app variable — GHAYMA_AUTH_APP_ID_<APP> — or with the slug itself.

Storage Options

  • "memory" (default) — tokens are lost on page refresh. Best for SSR frameworks (Next.js, Nuxt) where you manage tokens in cookies server-side.
  • "localStorage" — tokens persist across page refreshes. Best for SPAs (React, Vue) where the browser handles auth state.
// SPA — persist sessions across page refresh const auth = new GhaymaAuth({ appSlug: "my-auth-app", storage: "localStorage", });

Authentication

Register

const session = await auth.register({ email: "[email protected]", password: "securepass123", // min 8, max 128 chars name: "John Doe", // optional }); console.log(session.user); // { id, email, name, ... } console.log(session.access_token); // JWT (if email verification not required)

If the Auth App has email_verification_required enabled, no tokens are returned on registration. The user must verify their email first.

Login

const session = await auth.login({ email: "[email protected]", password: "securepass123", }); console.log(session.user); // User profile console.log(session.access_token); // JWT access token console.log(session.expires_in); // Seconds until expiry (default 900)

Logout

await auth.logout(); // Revokes the refresh token server-side and clears local session

Refresh Token

Tokens are refreshed automatically when autoRefresh is enabled. To refresh manually:

const tokens = await auth.refreshToken(); console.log(tokens.access_token); // New JWT

Refresh tokens are single-use. Each refresh returns a new refresh token. If a replaced refresh token is presented again before it expires, every session of that user ends and the call throws a 403 (stolen token protection). An expired token, or one from before the user’s sessions ended, throws a 401 and ends nothing.

User Profile

Get User

const user = await auth.getUser(); console.log(user.id); // "5729d517-..." console.log(user.email); // "[email protected]" console.log(user.name); // "John Doe" console.log(user.avatar_url); // "https://..." console.log(user.email_verified); // true console.log(user.provider); // "email" | "google" | "github"

Update Profile

const updated = await auth.updateUser({ name: "Jane Doe", avatar_url: "https://example.com/photo.jpg", metadata: { theme: "dark", lang: "fr" }, });

All fields are optional — only provided fields are updated.

metadata is user-owned (preferences, theme) — anyone can update their own. Roles and plans live in the separate app_metadata field, which is read-only here: it appears on the user object and inside the JWT as the app_metadata claim, but only the app’s developer can change it (via the Server SDK or Dashboard). Never store authorization data in metadata.

Change Password

await auth.changePassword({ current_password: "oldpass123", new_password: "newpass456", }); // Every session ends, this one included: the local session is cleared and // the user signs in again with the new password

Works for any account that has a password, whatever it signed up with. One without — created by a Google or GitHub sign-in, or taken over by one — gets 400 — this account has no password; set one with a password reset: forgotPassword() and resetPassword() set one.

Change Email

This covers email change for end-users of your app (Auth Apps). The platform’s own dashboard-user email change is at Account → Email Change.

await auth.changeEmail({ new_email: "[email protected]", current_password: "currentpass123", }); // A confirmation link is emailed to the new address. // The user stays signed in with the current email until they click it. // On confirm, every session ends and the user signs in with the new email.

Sessions do not end at request time. The user keeps using their current session until the confirmation link is clicked. Losing the session before confirming would strand them, so the platform ends sessions only on confirm, by design.

Email-based accounts only. OAuth users (Google, GitHub) get 400 — this account uses {provider} sign-in; change your email there first and must change at the upstream provider. Rate-limited to 3 requests per hour per user+app; confirmation tokens expire after 1 hour. A new request cancels any prior unconfirmed one. See the Email Change guide for the full flow.

Cancel Email Change

await auth.cancelEmailChange(); // Cancels a pending email-change request. Idempotent — safe even with no pending request.

Delete Account

// Email accounts — password confirmation required await auth.deleteAccount({ password: "mypassword" }); // OAuth accounts — no password needed await auth.deleteAccount();

Two-Factor Authentication

When an auth app has 2FA enabled (policy Optional or Enforced), a sign-in no longer always ends in a session. login() resolves to the pending step — check the shape — and the OAuth methods throw it as a TwoFactorRequiredError:

const result = await auth.login({ email, password }); if ("two_fa_required" in result) { // Enrolled user — ask for their authenticator code, then: const session = await auth.verify2FA({ challenge_token: result.challenge_token, code: userInput, // TOTP code or a recovery code }); } else if ("two_fa_enrollment_required" in result) { // App enforces 2FA and this user isn't enrolled yet: enrolling with the // enroll_token completes the login const { otpauth_uri } = await auth.enrollTotp({ enroll_token: result.enroll_token }); // Show otpauth_uri as a QR code, then confirm with the first code const res = await auth.confirmTotp({ code: userInput, enroll_token: result.enroll_token }); // res.recovery_codes are shown this once; the session is stored } else { // Plain session — signed in. }

A challenge lasts 5 minutes and works once, and every verify2FA() attempt spends one of its 5 tries; once they are gone the user signs in again from the start. An enroll_token lasts 10 minutes, with the same 5 tries on confirmTotp().

Update to @ghayma/sdk 1.3.0 before you turn 2FA on. Since 2026-09-30, Google and GitHub sign-ins ask for the second factor too. Earlier versions never receive tokens for those users, but they misread the pending step: handleOAuthFragment() returns false, and 1.2.0’s exchangeCodeForSession() and signInWithIdToken() report a sign-in without tokens. See Minimum versions for 2FA.

Enrol TOTP (authenticator app)

// 1. Start — render otpauth_uri as a QR code (or show the secret for // manual entry). Pass enroll_token only on the enforced-login path. const { secret, otpauth_uri } = await auth.enrollTotp(); // 2. Confirm with a code from the authenticator app. const res = await auth.confirmTotp({ code: "123456" }); // res.recovery_codes — 10 single-use codes, shown ONLY THIS ONCE. // Display them and offer a download; the user stores them safely.

A second confirmTotp() for the same enrolment (a double submit) throws 409 — TOTP is already enabled; the recovery codes from the first call stand. With an enroll_token the second call throws 401 — not authenticated instead, because the first one spent the token.

Recovery codes

Each code works once as a login code (in verify2FA) when the phone or authenticator is lost. getUser() exposes recovery_codes_left — prompt regeneration when few remain:

const { recovery_codes } = await auth.regenerateRecoveryCodes({ password: currentPassword, code: currentCode, // TOTP or an unused recovery code }); // Previous codes are all invalidated.

Disable 2FA

await auth.disable2FA({ password: currentPassword, code: currentCode }); // A localized security notice is emailed to the account address.

disable2FA() and regenerateRecoveryCodes() both ask for the account’s password, so an account without one (Google or GitHub sign-in) sets one with a password reset first.

Locked out (lost phone AND recovery codes)? The app owner resets 2FA from the Dashboard’s Users tab (or resetUser2FA in the Server SDK) — the user then signs in again and re-enrols.

Password Recovery

Forgot Password

await auth.forgotPassword({ email: "[email protected]" }); // Always returns success (prevents email enumeration)

Reset Password

// Use the token from the reset email await auth.resetPassword({ token: "reset-token-from-email", password: "newpassword123", });

Verify Reset Token

Pre-check a reset token without consuming it, so your page can show “link expired” before asking for a new password. Returns the account email for display.

const { valid, email } = await auth.verifyResetToken({ token });

Custom Reset Page

By default, reset emails link to a hosted form on auth.ghayma.tech. To use your own branded page instead, set Password Reset Page in your auth app’s settings (Dashboard → Auth → Settings). The URL must be https on one of your project’s domains or an origin in Allowed Origins (http only on localhost). Reset emails then link to your page with the token appended as ?token=:

// pages/reset-password.tsx — on your own domain const token = new URLSearchParams(window.location.search).get("token")!; // Strip the token from the URL so it doesn't linger in history window.history.replaceState(null, "", window.location.pathname); // Optional: pre-check and show who is resetting const { email } = await auth.verifyResetToken({ token }); // Then submit the new password await auth.resetPassword({ token, password: newPassword });

Security notes for your reset page:

  • Strip the token from the URL immediately (as above) — it is a one-hour, single-use credential.
  • Send a Referrer-Policy: no-referrer header (or meta tag) on the page.
  • Avoid third-party scripts on the reset page.

Resend Verification

await auth.resendVerification({ email: "[email protected]" });

OAuth

Two ways to sign a user in with Google or GitHub: a browser redirect, or, in a native app, the provider’s own sign-in SDK.

// Start: the user is sent to the provider. With flow: "pkce" the callback // receives a one-time code instead of tokens, so nothing sensitive is in the URL. await auth.signInWithOAuth("google", { redirectUri: "https://myapp.com/auth/callback", flow: "pkce", });
// On your callback page: exchanges ?code= for a session (or reads the // legacy #access_token= fragment) and scrubs the code from the address bar. if (await auth.handleOAuthRedirect()) { const user = await auth.getUser(); }

A provider refusal arrives as an AuthError with code: "oauth_error"; a spent or missing code as code: "invalid_grant"; a pending second factor as a TwoFactorRequiredError. redirectUri must be on one of your project’s domains, on localhost, or on an origin listed in the app’s Allowed Origins. Mobile deep links such as com.example.app://callback work the same way once listed there. See OAuth on Mobile.

Legacy fragment flow

Without flow: "pkce" (still the default), the callback keeps receiving tokens in the URL fragment, and handleOAuthFragment() keeps working; it throws a TwoFactorRequiredError when the fragment carries a pending second factor instead. getGoogleAuthUrl and getGitHubAuthUrl still build the redirect URL if you prefer to navigate yourself; pass a codeChallenge from generatePkce() to switch them to the code flow.

Native sign-in (Google)

When the device already holds a Google ID token (Google Sign-In SDK, Credential Manager), post it directly:

const session = await auth.signInWithIdToken({ provider: "google", idToken, nonce });

The token’s audience must be the app’s web client ID or one of the Native client IDs configured in the console. A rejected token is an AuthError with code: "invalid_token", and an address Google has not verified one with status 403 and code: "email_not_verified".

On a server

There is no browser to redirect, so drive the flow yourself:

import { generatePkce } from "@ghayma/sdk/client"; const { codeVerifier, codeChallenge } = await generatePkce(); const url = auth.getOAuthUrl("github", { redirectUri, codeChallenge }); // ... send the user to url, keep codeVerifier for the callback ... const session = await auth.exchangeCodeForSession({ code, codeVerifier });

Second factor on OAuth sign-in

When the app’s 2FA policy applies to the user, handleOAuthRedirect(), handleOAuthFragment(), exchangeCodeForSession() and signInWithIdToken() store no session and throw a TwoFactorRequiredError (1.3.0 and later; older versions misread the step). No SIGNED_IN fires, and a fragment carrying the step is cleared from the address bar like a tokens fragment. The error’s result is the pending step login() would have returned, and the sign-in finishes the same way:

import { TwoFactorRequiredError } from "@ghayma/sdk/client"; try { await auth.handleOAuthRedirect(); } catch (err) { if (!(err instanceof TwoFactorRequiredError)) throw err; const step = err.result; if ("two_fa_required" in step) { const code = await askUserForCode(); // TOTP or a recovery code await auth.verify2FA({ challenge_token: step.challenge_token, code }); } else { // Enforced policy, user not enrolled yet const { otpauth_uri } = await auth.enrollTotp({ enroll_token: step.enroll_token }); const code = await askUserForCode(otpauth_uri); // show the QR code first await auth.confirmTotp({ code, enroll_token: step.enroll_token }); } }

TwoFactorRequiredError extends AuthError, with status 200 and code two_fa_required or two_fa_enrollment_required, so catch it before a generic AuthError handler. After a PKCE redirect the one-time code is spent even though no session was stored: handleOAuthRedirect() clears it and its verifier whatever the answer, and running it again on the same page returns false.

Auth State Events

Subscribe to authentication state changes:

const unsubscribe = auth.onAuthStateChange((event, session) => { switch (event) { case "SIGNED_IN": console.log("User signed in"); break; case "SIGNED_OUT": console.log("User signed out"); break; case "TOKEN_REFRESHED": console.log("Token refreshed"); break; case "USER_UPDATED": console.log("Profile updated"); break; } }); // Stop listening unsubscribe();

Session State

// Check if authenticated auth.isAuthenticated(); // boolean // Get current access token auth.getAccessToken(); // string | null

A session can also end from elsewhere: a password reset or change, a confirmed email change, a Google or GitHub takeover, a stolen refresh token and the user being disabled each end every session of the user. Calls that need the stored access token then throw an AuthError with status 401 (403 while the user is disabled), and the next refresh is refused: the SDK clears the session and emits SIGNED_OUT.

Server Key

Calling the auth service from your own server — the recommended pattern for Next.js, where a route handler proxies login and registration — hides every end user behind a single IP address: your server’s. The per-IP rate limits would then count all of your users into one bucket, and real users would be refused on their first attempt.

The server key is how you avoid that. Every auth app has one (a ghs_ prefix followed by 48 hex characters). A server-side caller sends the key together with the end user’s IP address, and the auth service applies the per-IP limits to that address instead of your server’s:

const auth = new GhaymaAuth({ appSlug: "my-auth-app", serverKey: process.env.GHAYMA_AUTH_SERVER_KEY, // never in a browser }); // The address travels per call, so one instance serves every user. await auth.login({ email, password }, { clientIp });

clientIp travels in a trailing options object — login(params, { clientIp }) — accepted on register(), login(), forgotPassword(), resetPassword(), verifyResetToken() and resendVerification() — the calls whose limits are keyed by address. Without a valid serverKey a forwarded address is ignored, so it buys an attacker nothing.

Where the key comes from

Every site connected to an auth app gets it as an environment variable — nothing to ghayma env set, anywhere. It does not show up in ghayma env list, because it is derived at deploy time rather than stored alongside your own variables:

VariableWhose key it is
GHAYMA_AUTH_SERVER_KEYThe oldest auth app the site is connected to, so connecting a second app never repoints a running one
GHAYMA_AUTH_SERVER_KEY_<APP>That app — one per auth app the site is connected to. <APP> is the slug uppercased with every non-alphanumeric character replaced by _ (my-app → GHAYMA_AUTH_SERVER_KEY_MY_APP)

The app’s id and its two endpoints are derived beside it, under the same two shapes: GHAYMA_AUTH_APP_ID, GHAYMA_AUTH_URL and GHAYMA_AUTH_PUBLIC_URL. See Automatically injected variables.

On a Ghayma-hosted Node app the SDK picks the injected key up by itself, so serverKey only has to be passed explicitly when your server runs outside Ghayma — or when you would rather be explicit about which app’s key you mean. For those deployments, read the key from Dashboard → your project → Auth → your app → Server key, where you can also rotate it.

The server key is a secret — it must never reach a browser. Keep it in server-only code (route handlers, API servers, workers), never in a NEXT_PUBLIC_* / VITE_* variable or anything bundled for the client. Constructing GhaymaAuth with a serverKey in a browser throws.

Keys are stored encrypted at rest, and rotation takes effect immediately — the old key stops working the moment you rotate, so update any external server holding a copy. Apps hosted on Ghayma pick the new key up on their own (right away where possible, and always on their next deploy).

Without the SDK

Send the two headers yourself, with the visitor’s IP from request.headers.get("x-real-ip") as X-Ghayma-Client-IP (details):

POST /v1/{app-slug}/login X-Ghayma-Server-Key: ghs_... X-Ghayma-Client-IP: 203.0.113.7 Content-Type: application/json

The endpoints stay public — a request with no server key is never rejected for lacking one, it is simply counted against the address the platform sees.

Rate Limits

Every auth endpoint is throttled. Buckets are scoped to the endpoint, the auth app and the caller, so one app’s traffic never throttles another app’s users, and one busy endpoint never blocks the rest.

Three layers apply at once — a request has to pass all three.

1. Per caller. The caller is the end user’s IP address: the one the platform sees, or the one you forward with a server key. Some endpoints key on the address and the email, so a shared address still supports several honest users.

ActionSDK callLimitCounted per
Registerregister()5 / 15 minIP
Loginlogin()10 / 15 minIP
Loginlogin()5 / 15 minIP + email
Refresh tokenrefreshToken()30 / 15 minIP
Forgot passwordforgotPassword()3 / hourIP + email
Reset passwordresetPassword()5 / 15 minIP
Verify reset tokenverifyResetToken()10 / 15 minIP
Resend verificationresendVerification()3 / 5 minIP + email
Verify 2FAverify2FA()10 / 15 minIP
Request email changechangeEmail()3 / houruser
Confirm email changelink click5 / 5 minIP

2. Per identity. Registration is also capped at 3 per hour for the same app + email, whatever the address. Switching IP does not move an identity-keyed limit.

3. Per app. A ceiling for the whole auth app, across every address:

ActionCeiling
Register100 / 15 min
Login600 / 15 min
Refresh token5,000 / 15 min
Forgot password50 / hour
Reset password200 / 15 min

Handling a 429

A throttled request answers 429 with { "error": "...", "code": "rate_limited" } and a Retry-After header holding the number of seconds to wait. The SDK surfaces both on AuthError:

try { await auth.register({ email, password }, { clientIp }); } catch (err) { if (err instanceof AuthError && err.code === "rate_limited") { setMessage(`Too many attempts. Try again in ${err.retryAfter}s.`); return; } throw err; }

Error Handling

import { GhaymaAuth, AuthError } from "@ghayma/sdk/client"; try { await auth.login({ email: "[email protected]", password: "wrong" }); } catch (err) { if (err instanceof AuthError) { console.error(err.message); // "invalid email or password" console.error(err.status); // 401 console.error(err.code); // "request_failed" — see the table below console.error(err.retryAfter); // seconds to wait — 429 only } }
codeWhen
rate_limited429; read retryAfter
invalid_requestA malformed request, or a rejected field
invalid_grantA spent, expired or mismatched one-time code, or no PKCE verifier for the redirect
invalid_tokenA provider ID token that failed verification
email_not_verifiedsignInWithIdToken(): Google has not verified the address (403)
oauth_errorThe provider handed back ?error= on the redirect
two_fa_required, two_fa_enrollment_requiredA TwoFactorRequiredError (status 200)
timeout, network_errorNo answer in time (408), or the request never reached the service (0)
request_failedAnything the service did not label — a wrong password, for one

React Example

import { useState, useEffect } from "react"; import { GhaymaAuth } from "@ghayma/sdk/client"; const auth = new GhaymaAuth({ appSlug: "my-auth-app", storage: "localStorage", }); function App() { const [user, setUser] = useState(null); useEffect(() => { // Check for existing session if (auth.isAuthenticated()) { auth.getUser().then(setUser).catch(() => setUser(null)); } // Listen for auth changes return auth.onAuthStateChange((event) => { if (event === "SIGNED_OUT") setUser(null); }); }, []); async function handleLogin(email: string, password: string) { const { user } = await auth.login({ email, password }); setUser(user); } async function handleLogout() { await auth.logout(); setUser(null); } if (!user) return <LoginForm onLogin={handleLogin} />; return <Dashboard user={user} onLogout={handleLogout} />; }

Next.js Example (Server-Side)

For Next.js, use "memory" storage and manage tokens in httpOnly cookies. Because the auth service sees your server rather than the visitor, the route handler forwards the visitor’s address as clientIp — see Server Key:

lib/auth.ts
import { GhaymaAuth } from "@ghayma/sdk/client"; // Client-side instance (browser) — never give this one a serverKey. export const auth = new GhaymaAuth({ appSlug: "my-auth-app", storage: "localStorage", });
app/api/auth/login/route.ts
import { NextRequest, NextResponse } from "next/server"; import { cookies } from "next/headers"; import { GhaymaAuth, AuthError } from "@ghayma/sdk/client"; export async function POST(req: NextRequest) { const { email, password } = await req.json(); // The visitor's IP, which Ghayma's edge writes to `X-Real-IP` // (see /guides/visitor-ip), forwarded so the rate limits count the // visitor and not this server. const clientIp = req.headers.get("x-real-ip") ?? undefined; // One instance per request — a session must never be shared between // concurrent users. serverKey comes from the injected env on Ghayma. const auth = new GhaymaAuth({ appSlug: process.env.GHAYMA_AUTH_APP_ID!, storage: "memory", }); try { // If your app enables 2FA, branch on the login result first — see // Two-Factor Authentication above. const session = await auth.login({ email, password }, { clientIp }); const cookieStore = await cookies(); cookieStore.set("auth_token", session.access_token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 900, }); cookieStore.set("refresh_token", session.refresh_token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 604800, }); return NextResponse.json({ user: session.user }); } catch (err) { if (!(err instanceof AuthError)) throw err; // Pass 401s and 429s (with their Retry-After) back to the browser. return NextResponse.json( { error: err.message, code: err.code }, { status: err.status, headers: err.retryAfter ? { "Retry-After": String(err.retryAfter) } : undefined, }, ); } }

Compatibility

The SDK works in any JavaScript runtime with native fetch:

  • Browsers (Chrome, Firefox, Safari, Edge)
  • Node.js 18+
  • Deno
  • Bun
  • React Native