Skip to Content
Framework GuidesOAuth Providers (Google & GitHub)

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 them

When 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

  1. A deployed app on Ghayma
  2. An Auth App created and linked to your project (see Auth Integration)
  3. 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:

VariableExamplePurpose
GHAYMA_AUTH_URLhttp://auth-service...svc.cluster.local:9000/v1/my-appServer-to-server calls only
GHAYMA_AUTH_PUBLIC_URLhttps://auth.ghayma.tech/v1/my-appBrowser-facing OAuth redirects
GHAYMA_AUTH_APP_IDmy-appAuth app identifier
GHAYMA_AUTH_SERVER_KEYghs_...Server-side calls on a visitor’s behalf (server key)
APP_URLhttps://myapp.ghayma.appYour 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/ or https://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

  1. Go to Google Cloud Console  > APIs & Services > Credentials
  2. Create an OAuth 2.0 Client ID (Web application type)
  3. Add to Authorized redirect URIs:
https://auth.ghayma.tech/v1/{APP_ID}/callback/google

Replace {APP_ID} with your auth app ID (e.g., my-app).

  1. Copy the Client ID and Client Secret
  2. In the Ghayma dashboard, go to your Auth App settings and enter the Google credentials

Step 4: Set Up GitHub OAuth

  1. Go to GitHub > Settings > Developer settings > OAuth Apps > New OAuth App
  2. Set Homepage URL to your app’s domain (e.g., https://myapp.ghayma.app)
  3. Set Authorization callback URL to:
https://auth.ghayma.tech/v1/{APP_ID}/callback/github

Replace {APP_ID} with your auth app ID (e.g., my-app).

  1. Copy the Client ID and generate a Client Secret
  2. 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)

app/api/auth/google/route.ts
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)}` ); }
app/api/auth/github/route.ts
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=Bearer

Since fragments are only accessible by client-side JavaScript, you need a client component to read them:

app/auth/callback/page.tsx
"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:

app/api/auth/set-session/route.ts
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:

app/api/auth/2fa/verify/route.ts
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

app/login/page.tsx
<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:

ParameterDescription
access_tokenRS256-signed JWT (15 minutes default)
refresh_tokenOpaque token (7 days default)
expires_inAccess token TTL in seconds
token_typeAlways 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
ParameterDescription
two_fa_requiredtrue: the user holds a second factor
challenge_tokenSpend it at POST /2fa/verify with the user’s code. 5 minutes, 5 tries, single use
two_fa_enrollment_requiredtrue: the policy is Enforced and the user is not enrolled yet
enroll_tokenAuthorizes 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
methodsComma-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.

ParameterDescription
code_challengeBASE64URL(SHA-256(code_verifier)) without padding
code_challenge_methodOnly 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

server.ts
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.

FlowWhere 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-inPOST /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:

SDKMethodsError
@ghayma/sdk/clienthandleOAuthRedirect, handleOAuthFragment, exchangeCodeForSession, signInWithIdTokenTwoFactorRequiredError
ghayma_auth (Dart)handleRedirect, exchangeCode, signInWithIdTokenTwoFactorRequiredException
ghayma/sdk (PHP)exchangeCode, signInWithIdTokenTwoFactorRequiredException

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 error set to Your Google account's email address is not verified (in the fragment, or in the query for PKCE), and native sign-in answers 403 with "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 error set to No verified email found on GitHub account.

What happens next depends on the account that address matches:

Account with that emailThe sign-in
NoneCreates the user (it counts toward the app’s user limit)
Email verified, or no passwordSigns in to it. A password on the account keeps working
A password, email never verifiedTakes 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 google or github account (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

ProblemCauseFix
ERR_NAME_NOT_RESOLVED for *.svc.cluster.localUsing GHAYMA_AUTH_URL (internal) for browser redirectsUse GHAYMA_AUTH_PUBLIC_URL
redirect_uri is localhost:3000 in productionUsing 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 localhostUse 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 linkOnly an exact entry covers a custom schemeList the exact scheme://host, e.g. com.example.app://callback
"*" is no longer allowed: … when saving Allowed Origins* is no longer acceptedRemove it; your project’s domains and localhost are always allowed
Google redirect_uri_mismatchWrong callback URL in Google ConsoleUse https://auth.ghayma.tech/v1/{APP_ID}/callback/google
"GitHub OAuth not configured for this app"Missing credentials in auth app configAdd Client ID and Secret in dashboard
Tokens not persisted after callbackFragment (#) data not accessible server-sideUse client-side JS to read fragment, then POST to server route
Callback page stuck once 2FA is onThe fragment carries two_fa_required or two_fa_enrollment_required, not tokensHandle 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 addressThe user verifies the address with Google
"No verified email found on GitHub account"The GitHub account has no verified addressThe user verifies one in GitHub → Settings → Emails
A user’s password stops working after a Google or GitHub sign-inThe account’s email was never verified, so the provider took it overThe user sets a new password with a password reset