OAuth Providers (Google & GitHub)
Set up social login for apps deployed on Ghayma using Google and GitHub OAuth.
Overview
Ghayma Auth handles the entire OAuth flow for you. Your app just redirects the browser to the auth service, which manages the provider handshake and returns tokens to your app.
User clicks "Continue with Google"
-> Browser hits your app route: GET /api/auth/google
-> App redirects to: https://auth.ghayma.tech/v1/{APP_ID}/auth/google?redirect_uri=https://myapp.com/auth/callback
-> Auth service redirects to Google with its own callback
-> User authenticates with Google
-> Google redirects back to auth service
-> Auth service redirects to your app: https://myapp.com/auth/callback#access_token=...&refresh_token=...
-> App reads tokens from URL fragment and stores themWhen the app’s 2FA policy applies to the user, that last redirect carries a pending second factor instead of tokens — see Two-Factor Authentication.
Prerequisites
- A deployed app on Ghayma
- An Auth App created and linked to your project (see Auth Integration)
- A Google Cloud project or GitHub OAuth App (setup below)
Step 1: Environment Variables
When an Auth App is linked to your project, the platform auto-injects these env vars:
| Variable | Example | Purpose |
|---|---|---|
GHAYMA_AUTH_URL | http://auth-service...svc.cluster.local:9000/v1/my-app | Server-to-server calls only |
GHAYMA_AUTH_PUBLIC_URL | https://auth.ghayma.tech/v1/my-app | Browser-facing OAuth redirects |
GHAYMA_AUTH_APP_ID | my-app | Auth app identifier |
GHAYMA_AUTH_SERVER_KEY | ghs_... | Server-side calls on a visitor’s behalf (server key) |
APP_URL | https://myapp.ghayma.app | Your app’s public domain |
Never use GHAYMA_AUTH_URL for browser redirects. It’s an internal Kubernetes DNS name (*.svc.cluster.local) that browsers cannot resolve. Always use GHAYMA_AUTH_PUBLIC_URL for any URL the browser navigates to.
Next.js NEXT_PUBLIC_* vars are inlined at build time. If NEXT_PUBLIC_APP_URL isn’t set during the build step, it will have the wrong value at runtime. For server-side route handlers, use APP_URL or derive the URL from request headers instead.
Step 2: Check Allowed Origins
Your project’s own domains need no setup. Every domain of every site in the auth app’s project (its .ghayma.app URLs and your custom domains) and http://localhost / http://127.0.0.1 on any port are always allowed as a redirect_uri. The console lists them under Allowed Origins → Always allowed. A custom domain still waiting for DNS is allowed once it is live.
Add an origin to Allowed Origins only when the page that signs users in lives outside the project, such as a frontend hosted elsewhere or a preview URL. * is no longer accepted.
Enter an origin, not a URL: scheme://host[:port], with no path and no trailing slash.
- Correct:
https://app.example.com - Refused:
https://app.example.com/orhttps://app.example.com/auth/callback
Mobile apps have no web origin. Add the app’s deep-link origin instead, exactly as the app registers it — for example com.example.app://callback. See OAuth on Mobile.
Step 3: Set Up Google OAuth
- Go to Google Cloud Console > APIs & Services > Credentials
- Create an OAuth 2.0 Client ID (Web application type)
- Add to Authorized redirect URIs:
https://auth.ghayma.tech/v1/{APP_ID}/callback/googleReplace {APP_ID} with your auth app ID (e.g., my-app).
- Copy the Client ID and Client Secret
- In the Ghayma dashboard, go to your Auth App settings and enter the Google credentials
Step 4: Set Up GitHub OAuth
- Go to GitHub > Settings > Developer settings > OAuth Apps > New OAuth App
- Set Homepage URL to your app’s domain (e.g.,
https://myapp.ghayma.app) - Set Authorization callback URL to:
https://auth.ghayma.tech/v1/{APP_ID}/callback/githubReplace {APP_ID} with your auth app ID (e.g., my-app).
- Copy the Client ID and generate a Client Secret
- In the Ghayma dashboard, go to your Auth App settings and enter the GitHub credentials
The callback URL pattern for all OAuth providers is: https://auth.ghayma.tech/v1/{APP_ID}/callback/{provider}
This is the URL you register with each provider. It is NOT the same as redirect_uri — that points back to your app.
Step 5: App Code
Your app needs route handlers that redirect the browser to the auth service, and a callback page that receives the tokens.
OAuth Route Handlers (Next.js)
import { NextRequest, NextResponse } from "next/server";
function getAppUrl(req: NextRequest): string {
const proto = req.headers.get("x-forwarded-proto") || "https";
const host = req.headers.get("x-forwarded-host") || req.headers.get("host");
if (host) return `${proto}://${host}`;
return process.env.APP_URL || req.nextUrl.origin;
}
export async function GET(req: NextRequest) {
const authUrl = process.env.GHAYMA_AUTH_PUBLIC_URL!;
const appUrl = getAppUrl(req);
const callbackUrl = `${appUrl}/auth/callback`;
return NextResponse.redirect(
`${authUrl}/auth/google?redirect_uri=${encodeURIComponent(callbackUrl)}`
);
}import { NextRequest, NextResponse } from "next/server";
function getAppUrl(req: NextRequest): string {
const proto = req.headers.get("x-forwarded-proto") || "https";
const host = req.headers.get("x-forwarded-host") || req.headers.get("host");
if (host) return `${proto}://${host}`;
return process.env.APP_URL || req.nextUrl.origin;
}
export async function GET(req: NextRequest) {
const authUrl = process.env.GHAYMA_AUTH_PUBLIC_URL!;
const appUrl = getAppUrl(req);
const callbackUrl = `${appUrl}/auth/callback`;
return NextResponse.redirect(
`${authUrl}/auth/github?redirect_uri=${encodeURIComponent(callbackUrl)}`
);
}Use x-forwarded-proto and x-forwarded-host headers to derive your app’s public URL. These are set by the Kubernetes ingress and always reflect the correct domain. Do NOT use req.nextUrl.origin — behind the ingress it returns the container’s internal address.
Callback Page
After authentication, the auth service redirects back to your app with tokens in the URL fragment (#) — or, when a second factor is due, the pending step instead:
https://myapp.com/auth/callback#access_token=...&refresh_token=...&expires_in=900&token_type=BearerSince fragments are only accessible by client-side JavaScript, you need a client component to read them:
"use client";
import { useEffect, useState, type FormEvent } from "react";
import { useRouter } from "next/navigation";
import { TotpEnrollment } from "./totp-enrollment";
export default function AuthCallback() {
const router = useRouter();
// Both tokens stay in memory: never in storage or the URL
const [challengeToken, setChallengeToken] = useState<string | null>(null);
const [enrollToken, setEnrollToken] = useState<string | null>(null);
const [code, setCode] = useState("");
useEffect(() => {
const hash = window.location.hash.substring(1);
const params = new URLSearchParams(hash);
// Tokens and challenges should not linger in the address bar
window.history.replaceState(null, "", window.location.pathname);
const accessToken = params.get("access_token");
const refreshToken = params.get("refresh_token");
const error = params.get("error");
if (error) {
router.push(`/login?error=${encodeURIComponent(error)}`);
return;
}
// The app's 2FA policy applies: no tokens until the second factor
if (params.get("two_fa_required") === "true") {
setChallengeToken(params.get("challenge_token"));
return;
}
if (params.get("two_fa_enrollment_required") === "true") {
setEnrollToken(params.get("enroll_token"));
return;
}
if (accessToken && refreshToken) {
// Option 1: Store in cookies via a server route (recommended for httpOnly)
fetch("/api/auth/set-session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ accessToken, refreshToken }),
}).then(() => router.push("/dashboard"));
// Option 2: Store directly in localStorage (simpler but less secure)
// localStorage.setItem("access_token", accessToken);
// localStorage.setItem("refresh_token", refreshToken);
// router.push("/dashboard");
}
}, [router]);
async function verify(e: FormEvent) {
e.preventDefault();
const res = await fetch("/api/auth/2fa/verify", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ challengeToken, code }),
});
if (res.ok) router.push("/dashboard");
}
if (challengeToken) {
return (
<form onSubmit={verify}>
<input value={code} onChange={(e) => setCode(e.target.value)} placeholder="Authenticator or recovery code" />
<button type="submit">Verify</button>
</form>
);
}
if (enrollToken) {
// Enforced policy, user not enrolled yet
return <TotpEnrollment enrollToken={enrollToken} />;
}
return <div>Authenticating...</div>;
}The sample stops at TotpEnrollment, your enrolment step. It sends the
enroll_token to POST /2fa/totp/enroll, shows the otpauth_uri it gets back
as a QR code, then sends the first code with the same token to
POST /2fa/totp/confirm, which answers with the recovery codes and the
session. Proxy both calls through server routes like the verify route below;
the requests and answers are in
Auth Integration → Two-Factor Authentication.
If your app uses httpOnly cookies, add a server route to set them:
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const { accessToken, refreshToken } = await req.json();
const res = NextResponse.json({ ok: true });
res.cookies.set("token", accessToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 900, // 15 minutes (matches access token expiry)
});
res.cookies.set("refresh_token", refreshToken, {
httpOnly: true,
secure: true,
sameSite: "lax",
path: "/",
maxAge: 604800, // 7 days (matches refresh token expiry)
});
return res;
}The code the callback page collects for a two_fa_required fragment goes to a
route that finishes the sign-in server-side and sets the same cookies:
import { NextRequest, NextResponse } from "next/server";
export async function POST(req: NextRequest) {
const { challengeToken, code } = await req.json();
const serverKey = process.env.GHAYMA_AUTH_SERVER_KEY;
// 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(`${process.env.GHAYMA_AUTH_URL}/2fa/verify`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(serverKey && clientIp
? { "X-Ghayma-Server-Key": serverKey, "X-Ghayma-Client-IP": clientIp }
: {}),
},
body: JSON.stringify({ challenge_token: challengeToken, code }),
});
const data = await authRes.json();
if (!authRes.ok) {
return NextResponse.json({ error: data.error }, { status: authRes.status });
}
const res = NextResponse.json({ ok: true });
res.cookies.set("token", data.access_token, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 900 });
res.cookies.set("refresh_token", data.refresh_token, { httpOnly: true, secure: true, sameSite: "lax", path: "/", maxAge: 604800 });
return res;
}Login Page Buttons
<a href="/api/auth/google" className="btn btn-google">
Continue with Google
</a>
<a href="/api/auth/github" className="btn btn-github">
Continue with GitHub
</a>These are simple links — clicking them hits your server route, which redirects to the auth service.
Token Response
The auth service returns tokens in the URL fragment with these parameters:
| Parameter | Description |
|---|---|
access_token | RS256-signed JWT (15 minutes default) |
refresh_token | Opaque token (7 days default) |
expires_in | Access token TTL in seconds |
token_type | Always Bearer |
On error, the fragment contains error with a human-readable message.
For token refresh and verification, see the Auth Integration guide.
Pending second factor
When the app’s 2FA policy applies to the user, the fragment carries the next step instead of tokens:
https://myapp.com/auth/callback#two_fa_required=true&challenge_token=...&methods=totp
https://myapp.com/auth/callback#two_fa_enrollment_required=true&enroll_token=...&methods=totp| Parameter | Description |
|---|---|
two_fa_required | true: the user holds a second factor |
challenge_token | Spend it at POST /2fa/verify with the user’s code. 5 minutes, 5 tries, single use |
two_fa_enrollment_required | true: the policy is Enforced and the user is not enrolled yet |
enroll_token | Authorizes TOTP enrolment of this account, for 10 minutes. Whoever holds it can bind their own authenticator to the account, so handle it like a session token |
methods | Comma-separated second factors (totp) |
See Two-Factor Authentication for how the sign-in finishes.
One-time code instead of tokens (PKCE)
Start the flow with code_challenge and code_challenge_method=S256 and the
callback returns ?code= as a query parameter instead of a token fragment.
Exchange that code at POST /v1/{APP_ID}/oauth/exchange with the matching
code_verifier and you get the login’s answer as JSON: the session, or the
pending second factor. The code is spent either way, and errors come back as
?error= instead of in a fragment.
| Parameter | Description |
|---|---|
code_challenge | BASE64URL(SHA-256(code_verifier)) without padding |
code_challenge_method | Only S256 |
Recommended for mobile apps and for any app with 2FA, optional otherwise:
without code_challenge the callback keeps returning the fragment. See
OAuth on Mobile for the full flow and the
Auth Service API for the endpoint.
Express.js Example
import express from "express";
const app = express();
const AUTH_URL = process.env.GHAYMA_AUTH_PUBLIC_URL;
const APP_URL = process.env.APP_URL;
app.get("/auth/google", (req, res) => {
const callbackUrl = `${APP_URL}/auth/callback`;
res.redirect(`${AUTH_URL}/auth/google?redirect_uri=${encodeURIComponent(callbackUrl)}`);
});
app.get("/auth/github", (req, res) => {
const callbackUrl = `${APP_URL}/auth/callback`;
res.redirect(`${AUTH_URL}/auth/github?redirect_uri=${encodeURIComponent(callbackUrl)}`);
});Two-Factor Authentication
Since 2026-09-30, a Google or GitHub sign-in applies the app’s 2FA policy exactly like the password login (the rule): under an Optional or Enforced policy an enrolled user owes a code, and an Enforced policy sends a user without a second factor to enrolment. No tokens are issued until that step is done.
| Flow | Where the pending step arrives |
|---|---|
Fragment (no code_challenge) | The callback redirect, as a pending fragment |
| One-time code (PKCE) | The callback still returns ?code=; POST /oauth/exchange answers 200 with the pending body |
| Native sign-in | POST /oauth/id-token answers 200 with the pending body |
The pending bodies are the ones POST /login returns, and the sign-in finishes
the same way: POST /2fa/verify with the user’s code for a challenge_token,
or TOTP enrolment for an enroll_token — see
Auth Integration → Two-Factor Authentication.
With PKCE the step comes back as JSON, so no challenge or enroll token ever
travels in a URL.
The SDKs throw the step as a typed error and store no session:
| SDK | Methods | Error |
|---|---|---|
@ghayma/sdk/client | handleOAuthRedirect, handleOAuthFragment, exchangeCodeForSession, signInWithIdToken | TwoFactorRequiredError |
ghayma_auth (Dart) | handleRedirect, exchangeCode, signInWithIdToken | TwoFactorRequiredException |
ghayma/sdk (PHP) | exchangeCode, signInWithIdToken | TwoFactorRequiredException |
Update your SDK before you turn 2FA on — or now, if the policy is already
Optional or Enforced: @ghayma/sdk 1.3.0, ghayma_auth 0.2.0, PHP
ghayma/sdk 0.2.0, and @ghayma/auth 0.8.0 only with @ghayma/sdk 1.3.0
installed beside it. Older versions never receive tokens for a user the policy
applies to, but they misread the pending step, so that user’s Google or GitHub
sign-in breaks. Details: Minimum versions for 2FA.
Verified Emails and Existing Accounts
A provider sign-in finds the user by email address, so the auth service only accepts an address the provider has verified:
- Google — an account whose address Google has not verified is refused. The
callback redirects back with
errorset toYour Google account's email address is not verified(in the fragment, or in the query for PKCE), and native sign-in answers403with"code": "email_not_verified". - GitHub — the address comes from GitHub’s list of the user’s verified
addresses: the public profile email when GitHub lists it as verified, else the
primary one, else any verified one. With none, the callback redirects back
with
errorset toNo verified email found on GitHub account.
What happens next depends on the account that address matches:
| Account with that email | The sign-in |
|---|---|
| None | Creates the user (it counts toward the app’s user limit) |
| Email verified, or no password | Signs in to it. A password on the account keeps working |
| A password, email never verified | Takes the account over |
Provider takeover
Anyone can register a password account on an address they don’t own and never verify it. When a verified Google or GitHub sign-in matches such an account, the provider takes it over, in one step:
- the password is removed;
- every session of the account ends: the auth service refuses every access and refresh token issued before — including one a password sign-in racing the takeover has just received — and pending 2FA challenges (enrolment tokens included) and a pending email change stop working;
- the email is marked verified, and the account becomes a
googleorgithubaccount (it then changes its email at the provider, not through your app).
A second factor the account had enrolled stays, so under an Optional or
Enforced policy the sign-in that took over still asks for the code. The
takeover is logged as an oauth_takeover event, and a security notice goes to
the address, in the app’s email language, explaining why the password is gone.
A backend that verifies tokens itself against the JWKS still accepts an access token issued before the takeover until it expires (15 minutes by default) — see Ending every session.
The user sets a password again with a password reset. A reset also counts as verifying the email — following the emailed link proves the user controls the mailbox — so an account whose password was reset is linked, not taken over, at its next Google or GitHub sign-in.
In an app that does not require email verification, this is what a user who registered with a password and never verified the address meets: after their first Google or GitHub sign-in, the password stops working until they reset it. Apps that require email verification avoid it, since an unverified account cannot sign in with its password there in the first place.
Common Pitfalls
| Problem | Cause | Fix |
|---|---|---|
ERR_NAME_NOT_RESOLVED for *.svc.cluster.local | Using GHAYMA_AUTH_URL (internal) for browser redirects | Use GHAYMA_AUTH_PUBLIC_URL |
redirect_uri is localhost:3000 in production | Using NEXT_PUBLIC_APP_URL (build-time inlined) | Use x-forwarded-host headers or APP_URL |
"redirect_uri not in allowed origins" | The redirect is on a host outside the project, on a custom domain still waiting for DNS, or on http other than localhost | Use https on a project domain, list the origin in Allowed Origins, or wait until the domain is live |
"redirect_uri not in allowed origins" for a deep link | Only an exact entry covers a custom scheme | List the exact scheme://host, e.g. com.example.app://callback |
"*" is no longer allowed: … when saving Allowed Origins | * is no longer accepted | Remove it; your project’s domains and localhost are always allowed |
Google redirect_uri_mismatch | Wrong callback URL in Google Console | Use https://auth.ghayma.tech/v1/{APP_ID}/callback/google |
"GitHub OAuth not configured for this app" | Missing credentials in auth app config | Add Client ID and Secret in dashboard |
| Tokens not persisted after callback | Fragment (#) data not accessible server-side | Use client-side JS to read fragment, then POST to server route |
| Callback page stuck once 2FA is on | The fragment carries two_fa_required or two_fa_enrollment_required, not tokens | Handle the pending step and update the SDK (minimum versions) |
"Your Google account's email address is not verified" | Google has not verified the account’s address | The user verifies the address with Google |
"No verified email found on GitHub account" | The GitHub account has no verified address | The user verifies one in GitHub → Settings → Emails |
| A user’s password stops working after a Google or GitHub sign-in | The account’s email was never verified, so the provider took it over | The user sets a new password with a password reset |