Skip to Content
Framework GuidesAuth Integration

Auth Integration

Use Ghayma Auth Apps as a complete authentication backend for your web or mobile applications.

Overview

When you create an Auth App, your users get a full authentication system. There are two ways to integrate:

  1. Client SDK (@ghayma/sdk/client) — recommended for frontend apps
  2. Direct HTTP calls — for mobile apps, non-JavaScript backends, or custom integrations

Available Endpoints

EndpointMethodDescription
/v1/{app-slug}/registerPOSTRegister a new user
/v1/{app-slug}/loginPOSTLog in and get tokens
/v1/{app-slug}/refreshPOSTRefresh an expired access token
/v1/{app-slug}/logoutPOSTRevoke refresh token
/v1/{app-slug}/meGETGet current user profile
/v1/{app-slug}/mePATCHUpdate user profile
/v1/{app-slug}/meDELETEDelete user account
/v1/{app-slug}/change-passwordPOSTChange password
/v1/{app-slug}/email/change-requestPOSTRequest email change (sends confirmation link to new address)
/v1/{app-slug}/email/change-requestDELETECancel pending email change
/v1/{app-slug}/email/change-confirmGETConfirm email change via link click (public; returns HTML)
/v1/{app-slug}/forgot-passwordPOSTRequest password reset email
/v1/{app-slug}/reset-passwordPOSTReset password with token
/v1/{app-slug}/verify-reset-tokenPOSTPre-check a reset token (for custom reset pages)
/v1/{app-slug}/resend-verificationPOSTResend verification email
/v1/{app-slug}/2fa/verifyPOSTComplete a pending 2FA sign-in (password or OAuth)
/v1/{app-slug}/2fa/totp/enrollPOSTStart TOTP enrolment (JWT or enforced-login enroll_token)
/v1/{app-slug}/2fa/totp/confirmPOSTConfirm TOTP; returns recovery codes once
/v1/{app-slug}/2fa/disablePOSTDisable 2FA (password + current code)
/v1/{app-slug}/2fa/recovery/regeneratePOSTMint fresh recovery codes
/v1/{app-slug}/auth/googleGETStart Google OAuth flow (setup guide)
/v1/{app-slug}/auth/githubGETStart GitHub OAuth flow (setup guide)
/v1/{app-slug}/oauth/exchangePOSTTrade a PKCE one-time code for a session (mobile guide)
/v1/{app-slug}/oauth/id-tokenPOSTSign in with a Google ID token obtained natively (mobile guide)
/v1/{app-slug}/.well-known/jwks.jsonGETPublic signing keys (JWKS)

Base URL:

ContextURLEnv Var
Server-to-server (fetch from backend code)http://auth-service.paas-system.svc.cluster.local:9000GHAYMA_AUTH_URL
Browser-facing (OAuth redirects, login links)https://auth.ghayma.techGHAYMA_AUTH_PUBLIC_URL

Use the right URL for the right context. Server-side API calls (token verification, user management) must use the internal URL (GHAYMA_AUTH_URL). Browser redirects (OAuth flows, login pages) must use the public URL (GHAYMA_AUTH_PUBLIC_URL). Using the internal URL in the browser will fail — browsers cannot resolve *.svc.cluster.local addresses. See the Networking Guide for details.

Which browser origins may call it. Every domain of every site in your project (.ghayma.app URLs and custom domains) and http://localhost / http://127.0.0.1 on any port are always allowed. A frontend hosted anywhere else must be listed in the app’s Allowed Origins; * is no longer accepted.

The easiest way to integrate auth in a frontend app:

npm install @ghayma/sdk
import { GhaymaAuth } from "@ghayma/sdk/client"; const auth = new GhaymaAuth({ appSlug: "my-auth-app", storage: "localStorage", // persist sessions in browser }); // Register await auth.register({ email: "[email protected]", password: "securepass", name: "John" }); // Login — a session, or a pending second factor (see Two-Factor Authentication) const result = await auth.login({ email: "[email protected]", password: "securepass" }); // Get profile (auto-refreshes token if expired) const me = await auth.getUser(); // Update profile await auth.updateUser({ name: "John Doe" }); // Change password await auth.changePassword({ current_password: "securepass", new_password: "newpass123" }); // OAuth window.location.href = auth.getGoogleAuthUrl({ redirectUri: "https://myapp.com/callback" }); // Logout await auth.logout();

For full SDK documentation, see the Client SDK Reference.

Direct HTTP Integration

Calling from Your Own Server

When your backend calls these endpoints on a visitor’s behalf, every request arrives from a single address — your server’s — so the per-IP rate limits would count all of your users into one bucket.

Send your auth app’s server key together with the visitor’s address, and the limits are applied to the visitor instead:

POST /v1/{app-slug}/login X-Ghayma-Server-Key: ghs_... X-Ghayma-Client-IP: 203.0.113.7 Content-Type: application/json
  • X-Ghayma-Client-IP is honoured only when X-Ghayma-Server-Key matches the app being called. Everywhere else — browsers, attackers — a forwarded address is ignored and the request is counted against the address the platform actually sees.
  • Nothing is rejected for lacking a key: these endpoints stay public.
  • Every site connected to an auth app receives the key as an injected environment variable (GHAYMA_AUTH_SERVER_KEY, plus GHAYMA_AUTH_SERVER_KEY_<APP> per app); servers hosted elsewhere read it from Dashboard → project → Auth → your app → Server key. It is a secret — server-side only, never in browser code. See Client SDK → Server Key for the SDK equivalent (serverKey + a per-call clientIp).

Register

POST /v1/{app-slug}/register Content-Type: application/json { "email": "[email protected]", "password": "securepassword", "name": "John Doe" }

Response: 201

{ "user": { "id": "5729d517-...", "email": "[email protected]", "name": "John Doe", "email_verified": false }, "access_token": "eyJhbGciOiJSUzI1NiIs...", "refresh_token": "base64-encoded-token", "expires_in": 900, "token_type": "Bearer" }

Login

POST /v1/{app-slug}/login Content-Type: application/json { "email": "[email protected]", "password": "securepassword" }

Response: 200

{ "access_token": "eyJhbGciOiJSUzI1NiIs...", "refresh_token": "base64-encoded-token", "expires_in": 900, "token_type": "Bearer", "user": { "id": "5729d517-...", "email": "[email protected]", "name": "John Doe", "email_verified": true, "provider": "email", "created_at": "2026-03-15T21:05:12Z", "last_login_at": "2026-03-24T21:07:17Z" } }

When the app’s 2FA policy applies to the user, the 200 carries a pending second factor instead of tokens — see Two-Factor Authentication.

Two-Factor Authentication

The app’s 2FA policy (Two-Factor Authentication in the auth app’s Settings → General) decides whether a sign-in stops at a second factor. The same rule applies to the password login and to every Google or GitHub sign-in (OAuth Providers):

PolicyUser enrolledThe sign-in answers
Disabledeithera session
Optionalnoa session
Optional or Enforcedyestwo_fa_required
Enforcednotwo_fa_enrollment_required

Both pending answers are 200 with no tokens, so check for them before you read access_token.

two_fa_required — the user holds a second factor:

{ "two_fa_required": true, "challenge_token": "4c1d8ab2e3f5...", "methods": ["totp"], "phone_hint": "" }

Finish with a code from the user’s authenticator app, or one of their recovery codes:

POST /v1/{app-slug}/2fa/verify Content-Type: application/json { "challenge_token": "4c1d8ab2e3f5...", "code": "123456" }

The answer is the login’s session body. A challenge lasts 5 minutes, works once, and every attempt spends one of its 5 tries. A wrong code answers 401 {"error":"invalid code"}; an expired, spent or exhausted challenge, or one issued before the user’s sessions last ended, answers 401 {"error":"invalid or expired challenge"}, and the user signs in again from the start. /2fa/verify is limited to 10 calls per 15 minutes per caller, so a server that proxies it forwards the visitor’s address with the server key.

two_fa_enrollment_required — the policy is Enforced and the user has no second factor yet:

{ "two_fa_enrollment_required": true, "enroll_token": "7b2e9c40a1d6...", "methods": ["totp"] }

The enroll_token authorizes TOTP enrolment only, for 10 minutes. Send it with no Authorization header: the service reads a bearer token first and then ignores the enroll token.

POST /v1/{app-slug}/2fa/totp/enroll Content-Type: application/json { "enroll_token": "7b2e9c40a1d6..." }

The answer holds secret and otpauth_uri; render the URI as a QR code for the authenticator app, then confirm with the first code it shows:

POST /v1/{app-slug}/2fa/totp/confirm Content-Type: application/json { "code": "123456", "enroll_token": "7b2e9c40a1d6..." }

The confirm turns TOTP on and completes the sign-in: it answers enabled, the recovery_codes (10 single-use codes, shown this once) and the session fields (access_token, refresh_token, expires_in, token_type, user). Like a challenge, the enroll token gets 5 tries and works once.

A signed-in user enrols the same way with Authorization: Bearer <access_token> and no enroll token. A confirm that finds TOTP already on — a double submit on the signed-in path, or a second sign-in’s enroll token confirmed after the first — answers 409 {"error":"TOTP is already enabled"} and mints no second set of recovery codes. On the enforced path it brings no session, and a spent enroll token answers 401 {"error":"not authenticated"}, so the user signs in again and is asked for a code.

Turning 2FA off (/2fa/disable) and minting new recovery codes (/2fa/recovery/regenerate) take the account’s password and a current code, so an account without a password sets one with a password reset first. The app owner resets a user locked out of their factor (Reset a User’s 2FA).

Refresh Token

POST /v1/{app-slug}/refresh Content-Type: application/json { "refresh_token": "base64-encoded-token" }

Response: 200

{ "access_token": "eyJhbGciOiJSUzI1NiIs...", "refresh_token": "new-base64-encoded-token", "expires_in": 900, "token_type": "Bearer" }

Refresh tokens are single-use. Each refresh returns a new refresh token — store and use the latest one.

AnswerWhen
401 {"error":"refresh token expired"}The token is past its lifetime
401 {"error":"invalid refresh token"}Unknown, logged out, or issued before the user’s sessions last ended
403 {"error":"refresh token reuse detected, all sessions revoked"}A token already replaced, presented again before it expires. Someone else holds it, so every session of the user ends

An expired token, or one from before the sessions ended, ends nothing: only a replaced token still within its lifetime counts as theft.

Logout

POST /v1/{app-slug}/logout Content-Type: application/json { "refresh_token": "base64-encoded-token" }

Logout ends this one session: its refresh token stops working, and its access token stays valid until it expires. To end every session of the user, see Ending every session.

Get Current User

GET /v1/{app-slug}/me Authorization: Bearer {access_token}

Response: 200

{ "user": { "id": "5729d517-...", "email": "[email protected]", "name": "John Doe", "email_verified": true, "provider": "email", "created_at": "2026-03-15T21:05:12Z", "last_login_at": "2026-03-24T21:07:17Z" } }

Update Profile

PATCH /v1/{app-slug}/me Authorization: Bearer {access_token} Content-Type: application/json { "name": "Jane Doe", "avatar_url": "https://example.com/photo.jpg", "metadata": { "theme": "dark" } }

Change Password

POST /v1/{app-slug}/change-password Authorization: Bearer {access_token} Content-Type: application/json { "current_password": "oldpass123", "new_password": "newpass456" }

A password change ends every session of the user, the one that made the change included: the user signs in again with the new password on every device.

Any account that has a password can change it, whatever it signed up with. An account with no password — created by a Google or GitHub sign-in, or taken over by one — answers 400 {"error":"this account has no password; set one with a password reset"}. Forgot Password and Reset Password set one, and from then on it changes like any other.

Email Change

This covers email change for end-users of your app (registered via your Auth App). For changing the email on your own Ghayma dashboard account, see Account → Email Change. For a walkthrough including UI considerations, see the Email Change Guide.

A three-step, double-confirmed flow:

  1. The user requests the change with their new email + current password (JWT required).
  2. A confirmation link is emailed to the new address; a heads-up notice is emailed to the old address. The user keeps their current session.
  3. The user clicks the link, which hits the public GET /email/change-confirm endpoint. The change is applied atomically, every session of the user ends, and a final notice is sent to the old address.

OAuth-linked accounts cannot use this flow — their canonical email is pinned to the upstream provider.

A pending change is cancelled whenever the user’s sessions end: a password reset or change, a Google or GitHub sign-in that takes over the account, and the other cases under Ending every session.

Request Email Change

POST /v1/{app-slug}/email/change-request Authorization: Bearer {access_token} Content-Type: application/json { "new_email": "[email protected]", "current_password": "secret123" }

Rate-limited to 3 requests per hour per user+app. Any prior unconfirmed request for the same user is cancelled before the new one is issued, so there is always at most one live token per account.

Response: 200

{ "message": "Check your new email to confirm the change", "expires_at": "2026-05-14T16:00:00Z" }

Errors

StatuserrorWhen
400new_email and current_password requiredBody missing required fields or invalid email format
400new email must differ from currentSame as the address already on file
400this account uses google sign-in; change your email there firstOAuth-linked account (provider name varies)
400could not start email changeTarget already registered to another account (generic by design — does not leak account existence)
401current password is incorrectcurrent_password does not match
429rate limit exceededMore than 3 requests in the last hour — the body carries "code": "rate_limited" and the response a Retry-After header (Rate Limits)

Confirm Email Change

GET /v1/{app-slug}/email/change-confirm?token=...

Public — no Authorization header required. The token from the email link is the credential.

This endpoint returns an HTML page (success or error), not JSON. It mirrors /verify-email — the user lands here by clicking the link in their email, so the response is rendered directly in the browser. There is no SDK method for this step.

On success: the user row is updated atomically (email swapped, email_verified set to true, the link-token marked consumed) and, in the same transaction, every session of the user ends. A final notice is then sent to the old address. If the transaction fails, the page answers 500, nothing changes, and the link still works.

Rate-limited to 5 attempts per 5 minutes per IP to mitigate token-guessing.

Cancel Email Change

DELETE /v1/{app-slug}/email/change-request Authorization: Bearer {access_token}

Cancels the caller’s pending change. Idempotent — succeeds even with no pending request.

Response: 200

{ "message": "pending email change cancelled" }

Delete Account

DELETE /v1/{app-slug}/me Authorization: Bearer {access_token} Content-Type: application/json { "password": "currentpassword" }

Password confirmation is required for email accounts. OAuth-only accounts can send an empty body.

Forgot Password

POST /v1/{app-slug}/forgot-password Content-Type: application/json { "email": "[email protected]" }

Always returns success (prevents email enumeration). If the email exists, a reset link is sent.

Reset Password

POST /v1/{app-slug}/reset-password Content-Type: application/json { "token": "reset-token-from-email", "password": "newpassword123" }

A reset ends every session of the user and marks the email verified — following the emailed link proves the user controls the mailbox. Both happen in the transaction that sets the password: if it fails, the call answers 500, nothing changes, and the same link can be used again.

Verify Reset Token

POST /v1/{app-slug}/verify-reset-token Content-Type: application/json { "token": "reset-token-from-email" }

Returns { "valid": true, "email": "[email protected]" } without consuming the token, so a custom reset page can pre-check the link and show who is resetting. Invalid or expired tokens return 400.

Email Language

All end-user emails (verification, password reset, email change) are sent in the auth app’s Email Language — English (default), French, or Arabic (rendered right-to-left). Set it in the Dashboard under your auth app’s Settings → General. Changing an app’s settings is not part of the SDK.

Custom Reset Page

Reset emails link to the hosted form by default. Set Password Reset Page in the auth app’s settings to point them at your own page instead — the token arrives as ?token=, and your page calls reset-password (or the SDK’s resetPassword()). The URL must be https on one of the project’s domains or an origin in the app’s Allowed Origins (http only on localhost); if it ever fails validation, emails fall back to the hosted form. See Auth Client → Custom Reset Page for a full example and page-security notes.

Resend Verification Email

POST /v1/{app-slug}/resend-verification Content-Type: application/json { "email": "[email protected]" }

Sessions

A sign-in starts a session: an access token, a JWT that lasts the app’s JWT lifetime (15 minutes by default), and a refresh token (7 days by default) that the refresh endpoint trades for a new pair. Both lifetimes are set per app, under Token Expiry in the auth app’s Settings or with ghayma auth config.

Ending every session

Every session of a user ends at once on:

From then on the auth service refuses everything issued before:

  • every refresh token (401 {"error":"invalid refresh token"});
  • every access token, with the 401 {"error":"invalid or expired token"} an expired one gets — or 403 {"error":"account is disabled"} while the user stays disabled;
  • pending second-factor challenges and enrolment tokens, and a pending email change;
  • a one-time PKCE code, which fails at POST /oauth/exchange with invalid_grant.

The device that made the change is signed out too: after a password change, the user signs in again with the new password there as everywhere else. The JavaScript and Dart SDKs clear their session on changePassword() for that reason.

A password reset, a password change and an email confirmation end the sessions in the same transaction as the change: if it fails, the call answers 500 and nothing changes, so the same reset or confirmation link can be used again.

Access tokens carry a sep claim, the session generation they were issued in; it is how the auth service tells tokens from before an ending apart.

A backend that verifies tokens itself against the JWKS never hears of an ending: it keeps accepting an access token issued before until that token expires. For an action that must honour a sign-out at once, ask the auth service — GET /me refuses the token once the sessions have ended — or keep the app’s JWT lifetime short.

Rate Limits

Every runtime endpoint is throttled. Buckets are scoped to endpoint + auth app + caller, so one app’s traffic never throttles another app’s users. Three layers apply at once:

  1. Per caller — the visitor’s IP address, or the address you forward with a server key. Some endpoints key on the address and the email.
  2. Per identity — registration is also capped at 3 per hour for the same app + email, whatever the address, so changing IP does not move it.
  3. Per app — a ceiling across every address: register 100 / 15 min, login 600 / 15 min, refresh 5,000 / 15 min, forgot-password 50 / hour, reset-password 200 / 15 min.

The full per-endpoint table is on the Client SDK page — the numbers are the same however you call.

A throttled request answers:

HTTP/1.1 429 Too Many Requests Retry-After: 42 Content-Type: application/json { "error": "rate limit exceeded", "code": "rate_limited" }

Retry-After carries the seconds until the bucket refills — show it to the user instead of retrying blind. Branch on code, not on the message.

JWT Verification (Server-Side)

Access tokens are RS256-signed JWTs. Your backend verifies them locally against the app’s public signing key — there is no “verify this token” API call, so verification costs no network round-trip per request and keeps working even if the auth service is briefly unreachable.

The Server SDK (@ghayma/sdk) does not verify tokens. It administers your end users and reads your storage and databases. Verify tokens with the JWKS + jose recipe below.

JWKS Endpoint

GET /v1/{app-slug}/.well-known/jwks.json

Public — no Authorization header. Returns the app’s RSA public key as a JWK set, cacheable for 1 hour. Rotating the app’s keys (ghayma auth rotate-keys) publishes a new key here.

Token Claims

ClaimDescription
subUser ID (UUID)
emailUser’s email
email_verifiedWhether email is verified
audAuth App slug (the app_id — the same value in the /v1/{app-slug}/ URL)
isshttps://auth.ghayma.tech
expExpiration timestamp (15 min by default)
iatIssued at timestamp
app_metadataDeveloper-owned data — roles, plan, tenant id. Present only when set; see App Metadata
user_metadataUser-editable profile data. Present only when set
sepSession generation the token was issued in. Absent while it is 0. The auth service refuses a token from an earlier generation once the user’s sessions end

A verified token stays valid for your backend until exp, even after the user’s sessions have ended: see Ending every session for what to do when that matters.

The verification helper

Write this once and import it everywhere — Express, Next.js, workers, tests.

npm install jose
lib/auth.ts
import { createRemoteJWKSet, jwtVerify, type JWTPayload } from "jose"; // Always this literal, whether you fetch JWKS over the internal or the // public URL — the auth service signs `iss` with its public base URL. const ISSUER = "https://auth.ghayma.tech"; // Built lazily and cached for the process lifetime. Lazy matters twice: // `jose` caches the fetched keys on this object (so don't rebuild it per // request), and reading env at module scope breaks `next build`, which // collects page data without your runtime env vars. let jwks: ReturnType<typeof createRemoteJWKSet> | undefined; function getJwks() { if (!jwks) { const authUrl = process.env.GHAYMA_AUTH_URL; if (!authUrl) throw new Error("GHAYMA_AUTH_URL is not set"); // GHAYMA_AUTH_URL already ends in /v1/<app-slug> jwks = createRemoteJWKSet(new URL(`${authUrl}/.well-known/jwks.json`)); } return jwks; } export interface GhaymaClaims extends JWTPayload { sub: string; email: string; email_verified: boolean; app_metadata?: Record<string, unknown>; user_metadata?: Record<string, unknown>; sep?: number; } /** Verify an access token. THROWS on invalid, expired, or wrong-audience tokens. */ export async function verifyAccessToken(token: string): Promise<GhaymaClaims> { const { payload } = await jwtVerify(token, getJwks(), { audience: process.env.GHAYMA_AUTH_APP_ID, // your auth app's slug issuer: ISSUER, }); return payload as GhaymaClaims; }

jwtVerify throws on a bad, expired, or wrong-audience token — there is no valid boolean to check. Wrap it in try/catch and return 401; the Client SDK refreshes the token and retries.

Express middleware

server.ts
import express from "express"; import { verifyAccessToken, type GhaymaClaims } from "./lib/auth"; // Lets downstream handlers read `req.user` in TypeScript. declare global { namespace Express { interface Request { user?: GhaymaClaims } } } const app = express(); app.use("/api", async (req, res, next) => { const token = req.headers.authorization?.replace("Bearer ", ""); if (!token) return res.status(401).json({ error: "Unauthorized" }); try { req.user = await verifyAccessToken(token); next(); } catch { res.status(401).json({ error: "Invalid token" }); } });

Next.js middleware

middleware.ts
import { NextResponse, type NextRequest } from "next/server"; import { verifyAccessToken } from "@/lib/auth"; export async function middleware(req: NextRequest) { const token = req.cookies.get("auth_token")?.value; if (!token) return NextResponse.redirect(new URL("/login", req.url)); try { await verifyAccessToken(token); return NextResponse.next(); } catch { return NextResponse.redirect(new URL("/login", req.url)); } } export const config = { matcher: ["/dashboard/:path*"] };

Authorizing from the token

Roles set with updateUserAppMetadata ride along in the JWT, so authorization needs no extra lookup:

const claims = await verifyAccessToken(token); const roles = (claims.app_metadata?.roles as string[]) ?? []; if (!roles.includes("admin")) return res.status(403).json({ error: "Forbidden" });

Role changes land in the user’s token at their next refresh (up to 15 minutes by default). If a change can’t wait, disabling the user ends their sessions; once re-enabled they sign in again and get the new roles. Tokens issued before still verify in this backend until they expire, though: read the roles from GET /me for a check that must be current.

Next.js Integration

Environment Variables

Deployed on Ghayma there is nothing to set. Every site connected to an auth app is handed GHAYMA_AUTH_APP_ID, GHAYMA_AUTH_URL (the in-cluster endpoint), GHAYMA_AUTH_PUBLIC_URL and GHAYMA_AUTH_SERVER_KEY at deploy time — which is also why none of them appear in ghayma env list. A Next.js bundle reads the app id from NEXT_PUBLIC_GHAYMA_AUTH_APP_ID, derived as a build argument as well. See Automatically injected variables.

Set them yourself for local development, and on servers hosted outside Ghayma — with the public URL there, since the in-cluster hostname resolves only inside the cluster:

.env.local (local development)
GHAYMA_AUTH_URL=https://auth.ghayma.tech/v1/my-auth-app GHAYMA_AUTH_APP_ID=my-auth-app NEXT_PUBLIC_GHAYMA_AUTH_APP_ID=my-auth-app # Copy from Dashboard → project → Auth → your app → Server key GHAYMA_AUTH_SERVER_KEY=ghs_...

On a deployed site the platform’s value wins over one you set under the same name — the override is logged, never silent. Point an app at another project’s auth app under a name of your own instead.

API Route (Login)

app/api/auth/login/route.ts
import { NextRequest, NextResponse } from "next/server"; import { cookies } from "next/headers"; const AUTH_URL = process.env.GHAYMA_AUTH_URL!; const SERVER_KEY = process.env.GHAYMA_AUTH_SERVER_KEY; export async function POST(req: NextRequest) { const { email, password } = await req.json(); // The visitor's IP, as Ghayma's edge wrote it (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; const authRes = await fetch(`${AUTH_URL}/login`, { method: "POST", headers: { "Content-Type": "application/json", ...(SERVER_KEY && clientIp ? { "X-Ghayma-Server-Key": SERVER_KEY, "X-Ghayma-Client-IP": clientIp } : {}), }, body: JSON.stringify({ email, password }), }); const data = await authRes.json(); if (!authRes.ok) { return NextResponse.json({ error: data.error }, { status: authRes.status }); } // A pending second factor carries no tokens: the browser collects the code, // then a route like this one calls /2fa/verify if (data.two_fa_required || data.two_fa_enrollment_required) { return NextResponse.json(data); } const cookieStore = await cookies(); cookieStore.set("auth_token", data.access_token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 900, }); cookieStore.set("refresh_token", data.refresh_token, { httpOnly: true, secure: process.env.NODE_ENV === "production", sameSite: "lax", maxAge: 604800, }); return NextResponse.json({ user: data.user }); }

Mobile App Integration

Mobile apps connect from outside the cluster, so use the external URL directly:

// iOS / Swift let url = URL(string: "https://auth.ghayma.tech/v1/my-auth-app/login")! var request = URLRequest(url: url) request.httpMethod = "POST" request.setValue("application/json", forHTTPHeaderField: "Content-Type") request.httpBody = try JSONEncoder().encode(["email": email, "password": password])
// Android / Kotlin val url = "https://auth.ghayma.tech/v1/my-auth-app/login" val body = """{"email":"$email","password":"$password"}""" // Use Retrofit, OkHttp, or Ktor

Mobile apps, desktop apps, and external servers always use https://auth.ghayma.tech. The internal URL is only needed for apps deployed on Ghayma.