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/sdkQuick 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)
});| Option | Type | Default | Description |
|---|---|---|---|
appSlug | string | — | Required. Your Auth App slug |
baseUrl | string | https://auth.ghayma.tech | Auth service URL |
storage | "memory" | "localStorage" | "memory" | Token storage strategy |
autoRefresh | boolean | true | Auto-refresh before expiry |
serverKey | string? | 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, sonext buildinlines 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_IDonto 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 sessionRefresh Token
Tokens are refreshed automatically when autoRefresh is enabled. To refresh manually:
const tokens = await auth.refreshToken();
console.log(tokens.access_token); // New JWTRefresh 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 passwordWorks 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-referrerheader (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.
Browser redirect with a one-time code (recommended)
// 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 | nullA 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:
| Variable | Whose key it is |
|---|---|
GHAYMA_AUTH_SERVER_KEY | The 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/jsonThe 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.
| Action | SDK call | Limit | Counted per |
|---|---|---|---|
| Register | register() | 5 / 15 min | IP |
| Login | login() | 10 / 15 min | IP |
| Login | login() | 5 / 15 min | IP + email |
| Refresh token | refreshToken() | 30 / 15 min | IP |
| Forgot password | forgotPassword() | 3 / hour | IP + email |
| Reset password | resetPassword() | 5 / 15 min | IP |
| Verify reset token | verifyResetToken() | 10 / 15 min | IP |
| Resend verification | resendVerification() | 3 / 5 min | IP + email |
| Verify 2FA | verify2FA() | 10 / 15 min | IP |
| Request email change | changeEmail() | 3 / hour | user |
| Confirm email change | link click | 5 / 5 min | IP |
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:
| Action | Ceiling |
|---|---|
| Register | 100 / 15 min |
| Login | 600 / 15 min |
| Refresh token | 5,000 / 15 min |
| Forgot password | 50 / hour |
| Reset password | 200 / 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
}
}code | When |
|---|---|
rate_limited | 429; read retryAfter |
invalid_request | A malformed request, or a rejected field |
invalid_grant | A spent, expired or mismatched one-time code, or no PKCE verifier for the redirect |
invalid_token | A provider ID token that failed verification |
email_not_verified | signInWithIdToken(): Google has not verified the address (403) |
oauth_error | The provider handed back ?error= on the redirect |
two_fa_required, two_fa_enrollment_required | A TwoFactorRequiredError (status 200) |
timeout, network_error | No answer in time (408), or the request never reached the service (0) |
request_failed | Anything 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:
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",
});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