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:
- Client SDK (
@ghayma/sdk/client) — recommended for frontend apps - Direct HTTP calls — for mobile apps, non-JavaScript backends, or custom integrations
Available Endpoints
| Endpoint | Method | Description |
|---|---|---|
/v1/{app-slug}/register | POST | Register a new user |
/v1/{app-slug}/login | POST | Log in and get tokens |
/v1/{app-slug}/refresh | POST | Refresh an expired access token |
/v1/{app-slug}/logout | POST | Revoke refresh token |
/v1/{app-slug}/me | GET | Get current user profile |
/v1/{app-slug}/me | PATCH | Update user profile |
/v1/{app-slug}/me | DELETE | Delete user account |
/v1/{app-slug}/change-password | POST | Change password |
/v1/{app-slug}/email/change-request | POST | Request email change (sends confirmation link to new address) |
/v1/{app-slug}/email/change-request | DELETE | Cancel pending email change |
/v1/{app-slug}/email/change-confirm | GET | Confirm email change via link click (public; returns HTML) |
/v1/{app-slug}/forgot-password | POST | Request password reset email |
/v1/{app-slug}/reset-password | POST | Reset password with token |
/v1/{app-slug}/verify-reset-token | POST | Pre-check a reset token (for custom reset pages) |
/v1/{app-slug}/resend-verification | POST | Resend verification email |
/v1/{app-slug}/2fa/verify | POST | Complete a pending 2FA sign-in (password or OAuth) |
/v1/{app-slug}/2fa/totp/enroll | POST | Start TOTP enrolment (JWT or enforced-login enroll_token) |
/v1/{app-slug}/2fa/totp/confirm | POST | Confirm TOTP; returns recovery codes once |
/v1/{app-slug}/2fa/disable | POST | Disable 2FA (password + current code) |
/v1/{app-slug}/2fa/recovery/regenerate | POST | Mint fresh recovery codes |
/v1/{app-slug}/auth/google | GET | Start Google OAuth flow (setup guide) |
/v1/{app-slug}/auth/github | GET | Start GitHub OAuth flow (setup guide) |
/v1/{app-slug}/oauth/exchange | POST | Trade a PKCE one-time code for a session (mobile guide) |
/v1/{app-slug}/oauth/id-token | POST | Sign in with a Google ID token obtained natively (mobile guide) |
/v1/{app-slug}/.well-known/jwks.json | GET | Public signing keys (JWKS) |
Base URL:
| Context | URL | Env Var |
|---|---|---|
| Server-to-server (fetch from backend code) | http://auth-service.paas-system.svc.cluster.local:9000 | GHAYMA_AUTH_URL |
| Browser-facing (OAuth redirects, login links) | https://auth.ghayma.tech | GHAYMA_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.
Using the Client SDK (Recommended)
The easiest way to integrate auth in a frontend app:
npm install @ghayma/sdkimport { 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/jsonX-Ghayma-Client-IPis honoured only whenX-Ghayma-Server-Keymatches 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, plusGHAYMA_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-callclientIp).
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):
| Policy | User enrolled | The sign-in answers |
|---|---|---|
| Disabled | either | a session |
| Optional | no | a session |
| Optional or Enforced | yes | two_fa_required |
| Enforced | no | two_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.
| Answer | When |
|---|---|
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:
- The user requests the change with their new email + current password (JWT required).
- 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.
- The user clicks the link, which hits the public
GET /email/change-confirmendpoint. 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
| Status | error | When |
|---|---|---|
400 | new_email and current_password required | Body missing required fields or invalid email format |
400 | new email must differ from current | Same as the address already on file |
400 | this account uses google sign-in; change your email there first | OAuth-linked account (provider name varies) |
400 | could not start email change | Target already registered to another account (generic by design — does not leak account existence) |
401 | current password is incorrect | current_password does not match |
429 | rate limit exceeded | More 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:
- a password reset or a password change;
- a confirmed email change;
- a Google or GitHub sign-in that takes the account over;
- a replaced refresh token presented again before it expires, read as theft (see Refresh Token);
- the user being disabled, from the Users tab of the Dashboard or the management API.
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 — or403 {"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/exchangewithinvalid_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:
- 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.
- 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.
- 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.jsonPublic — 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
| Claim | Description |
|---|---|
sub | User ID (UUID) |
email | User’s email |
email_verified | Whether email is verified |
aud | Auth App slug (the app_id — the same value in the /v1/{app-slug}/ URL) |
iss | https://auth.ghayma.tech |
exp | Expiration timestamp (15 min by default) |
iat | Issued at timestamp |
app_metadata | Developer-owned data — roles, plan, tenant id. Present only when set; see App Metadata |
user_metadata | User-editable profile data. Present only when set |
sep | Session 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 joseimport { 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
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
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:
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)
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 KtorMobile apps, desktop apps, and external servers always use https://auth.ghayma.tech. The internal URL is only needed for apps deployed on Ghayma.