writing/tutorial/2026/08
TutorialAug 12, 2026·28 min read

Integrating Nafath (النفاذ الوطني الموحد) OAuth2/OIDC in TypeScript

Step-by-step guide to integrating Saudi Arabia's national digital identity platform (Nafath) into your TypeScript application using OAuth2/OIDC. Covers API access registration, authorization flow, token verification, identity claims extraction, and the Keycloak bridge alternative.

Every Saudi-facing application eventually hits the same wall: your users need to verify their national identity, and the gold standard is Nafath (نفاذ) — SDAIA's national SSO layer that 16.3 million Saudis already have on their phones.

This is not a "Sign in with Google" integration. Nafath ties into verified civil registry data. When a user authenticates through Nafath, you receive their national ID number, legal name, verified phone number, and date of birth — all confirmed against NIC (National Information Center) records. For platforms that need regulatory compliance, tenant screening, e-commerce KYC, or government service delivery, this is the only integration that actually counts.

The problem: there is no public developer portal with a quick-start guide. Searching for documentation surfaces SDAIA press releases, government policy pages, and a handful of freelancer job postings on mostaql.com asking someone to "please integrate Nafath on our platform." This guide fills that gap.

Prerequisites

Before starting, ensure you have:

  • Node.js 20+ and TypeScript 5.x
  • A Next.js 14+ project (or any TypeScript server framework — Express, Fastify, Hono)
  • An approved Nafath API application (covered in Step 1)
  • Basic understanding of OAuth2 authorization code flow
  • An HTTPS endpoint for your callback URL (Nafath rejects http://localhost for production)

What You Will Build

A complete Nafath authentication flow for a Next.js application:

  1. A "Login with Nafath" button that redirects to the national SSO
  2. An OAuth2 callback route that exchanges the authorization code for tokens
  3. A JWT verification layer that validates the ID token signature
  4. A session handler that extracts and stores verified identity claims
  5. A protected route that only admits users with a confirmed Saudi national ID

How Nafath Works

Nafath uses the iDART (Identity Access and Rights Token) service — SDAIA's OIDC-compliant identity provider. The flow is standard OAuth2 authorization code with one important difference: instead of entering a password, the user opens the Nafath app on their phone and approves the login request with biometrics or a PIN.

Your App                iDART / Nafath OIDC           User's Phone
   |                          |                             |
   |-- Authorization URL ---> |                             |
   |                          |-- Push notification ------> |
   |                          |                    [Approve]|
   |                          | <----- Biometric confirm ---|
   | <-- code (redirect) ---- |                             |
   |                          |                             |
   |-- code + client_secret ->|                             |
   | <-- access_token + id_token + refresh_token ---------- |
   |                          |                             |
   |-- UserInfo request ----> |                             |
   | <-- NID + name + phone - |                             |

The critical implementation detail: Nafath is a mobile-first authenticator. Your web application must be designed to handle the asynchronous approval — the user may take 30–60 seconds to confirm on their phone. You need a polling or webhook mechanism to handle this gracefully.

Step 1: Apply for API Access

Direct Nafath API integration requires an approved application from SDAIA/NIC. The process:

  1. Register your platform at my.gov.sa under "إدارة التطبيقات" (Application Management)
  2. Submit your platform description, redirect URIs, and intended use case
  3. SDAIA reviews and approves (typically 5–15 business days for private-sector apps)
  4. You receive a client_id and instructions for obtaining a client_secret

For licensed real estate, fintech, HR, and healthcare platforms: Nafath integration is often mandated by the relevant regulator (REGA, SAMA, HRSD, MOH). In those cases, your regulator liaison can expedite the approval process.

Alternative path — Rabet broker: If you need faster time-to-market, the Rabet platform (legacy.rabet.sa) offers brokered Nafath access using existing Absher credentials. This works but adds a third-party dependency and an older authentication mechanism. Direct iDART integration is the recommended production path.

Step 2: Project Setup

Install the required packages:

npm install openid-client jose zod
npm install -D @types/node
  • openid-client: OIDC-certified client library (handles discovery, PKCE, token exchange)
  • jose: JWT verification with JWKS support
  • zod: Runtime validation of identity claims

Create an environment configuration file:

# .env.local
NAFATH_CLIENT_ID=your_client_id_from_sdaia
NAFATH_CLIENT_SECRET=your_client_secret
NAFATH_ISSUER=https://iam.gov.sa
NAFATH_REDIRECT_URI=https://yourapp.sa/api/auth/nafath/callback
SESSION_SECRET=a_long_random_string_for_session_signing

Note: SDAIA may provide a different issuer URL specific to your approved application. Always use the URL from your approval letter — the iDART discovery endpoint follows the pattern ISSUER_URL/.well-known/openid-configuration.

Step 3: OIDC Client Configuration

Create a shared OIDC client module that caches the discovered configuration:

// lib/nafath-oidc.ts
import { Issuer, Client, generators } from "openid-client";
 
let nafathClient: Client | null = null;
 
export async function getNafathClient(): Promise<Client> {
  if (nafathClient) return nafathClient;
 
  const issuer = await Issuer.discover(process.env.NAFATH_ISSUER!);
 
  nafathClient = new issuer.Client({
    client_id: process.env.NAFATH_CLIENT_ID!,
    client_secret: process.env.NAFATH_CLIENT_SECRET!,
    redirect_uris: [process.env.NAFATH_REDIRECT_URI!],
    response_types: ["code"],
    // Nafath supports PKCE — always use it
    token_endpoint_auth_method: "client_secret_basic",
  });
 
  return nafathClient;
}
 
export function generatePKCE() {
  const codeVerifier = generators.codeVerifier();
  const codeChallenge = generators.codeChallenge(codeVerifier);
  return { codeVerifier, codeChallenge };
}

The Issuer.discover() call fetches the OIDC discovery document and populates all endpoints automatically. This means your code adapts if SDAIA updates endpoint URLs.

Step 4: Build the Authorization URL

Create the route that redirects users to Nafath:

// app/api/auth/nafath/route.ts  (Next.js App Router)
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getNafathClient, generatePKCE } from "@/lib/nafath-oidc";
import { generators } from "openid-client";
 
export async function GET() {
  const client = await getNafathClient();
  const { codeVerifier, codeChallenge } = generatePKCE();
  const state = generators.state();
  const nonce = generators.nonce();
 
  // Store PKCE verifier, state, and nonce in httpOnly cookies
  // These must survive the redirect round-trip
  const cookieStore = cookies();
  cookieStore.set("nafath_code_verifier", codeVerifier, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 600, // 10 minutes
    path: "/",
  });
  cookieStore.set("nafath_state", state, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 600,
    path: "/",
  });
  cookieStore.set("nafath_nonce", nonce, {
    httpOnly: true,
    secure: true,
    sameSite: "lax",
    maxAge: 600,
    path: "/",
  });
 
  const authorizationUrl = client.authorizationUrl({
    scope: "openid profile national_id phone",
    state,
    nonce,
    code_challenge: codeChallenge,
    code_challenge_method: "S256",
    // Nafath-specific parameter: request the mobile push notification
    acr_values: "urn:nafath:iam:push",
  });
 
  return NextResponse.redirect(authorizationUrl);
}

On scopes: Nafath's OIDC scopes are controlled by what SDAIA approved for your application. Common scopes:

  • openid — required baseline
  • profile — full legal name and date of birth
  • national_id — the NID number (most applications need this)
  • phone — NIC-verified mobile number
  • address — registered address (requires separate approval)

Step 5: Handle the OAuth Callback

// app/api/auth/nafath/callback/route.ts
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
import { getNafathClient } from "@/lib/nafath-oidc";
import { verifyNafathToken } from "@/lib/nafath-verify";
import { createSession } from "@/lib/session";
 
export async function GET(request: NextRequest) {
  const cookieStore = cookies();
  const codeVerifier = cookieStore.get("nafath_code_verifier")?.value;
  const expectedState = cookieStore.get("nafath_state")?.value;
  const nonce = cookieStore.get("nafath_nonce")?.value;
 
  // Validate required session cookies
  if (!codeVerifier || !expectedState || !nonce) {
    return NextResponse.redirect("/auth/error?reason=missing_session");
  }
 
  try {
    const client = await getNafathClient();
    const params = client.callbackParams(request.url);
 
    // Exchange authorization code for tokens
    // openid-client validates state automatically
    const tokenSet = await client.callback(
      process.env.NAFATH_REDIRECT_URI!,
      params,
      {
        code_verifier: codeVerifier,
        state: expectedState,
        nonce,
      }
    );
 
    if (!tokenSet.id_token) {
      throw new Error("No ID token in response");
    }
 
    // Verify and extract identity claims
    const identity = await verifyNafathToken(tokenSet.id_token, nonce);
 
    // Create application session
    const sessionToken = await createSession({
      nationalId: identity.national_id,
      fullName: identity.name,
      phone: identity.phone_number,
      birthdate: identity.birthdate,
      accessToken: tokenSet.access_token!,
      expiresAt: tokenSet.expires_at!,
    });
 
    // Clear PKCE cookies
    cookieStore.delete("nafath_code_verifier");
    cookieStore.delete("nafath_state");
    cookieStore.delete("nafath_nonce");
 
    const response = NextResponse.redirect("/dashboard");
    response.cookies.set("session", sessionToken, {
      httpOnly: true,
      secure: true,
      sameSite: "strict",
      maxAge: 60 * 60 * 8, // 8 hours
      path: "/",
    });
 
    return response;
  } catch (error) {
    console.error("Nafath callback error:", error);
    return NextResponse.redirect("/auth/error?reason=callback_failed");
  }
}

Step 6: Verify the ID Token

Never trust an ID token without verifying its signature. Nafath's iDART publishes a JWKS endpoint — use it:

// lib/nafath-verify.ts
import { createRemoteJWKSet, jwtVerify } from "jose";
import { z } from "zod";
 
const NafathClaimsSchema = z.object({
  sub: z.string(),
  national_id: z.string().regex(/^\d{10}$/),  // Saudi NID is always 10 digits
  name: z.string().min(1),
  phone_number: z.string().optional(),
  birthdate: z.string().optional(),
  iss: z.string(),
  aud: z.union([z.string(), z.array(z.string())]),
  exp: z.number(),
  iat: z.number(),
  nonce: z.string().optional(),
});
 
export type NafathClaims = z.infer<typeof NafathClaimsSchema>;
 
let jwks: ReturnType<typeof createRemoteJWKSet> | null = null;
 
function getJWKS() {
  if (!jwks) {
    jwks = createRemoteJWKSet(
      new URL(`${process.env.NAFATH_ISSUER}/jwks`)
    );
  }
  return jwks;
}
 
export async function verifyNafathToken(
  idToken: string,
  nonce: string
): Promise<NafathClaims> {
  const { payload } = await jwtVerify(idToken, getJWKS(), {
    issuer: process.env.NAFATH_ISSUER!,
    audience: process.env.NAFATH_CLIENT_ID!,
  });
 
  // Verify nonce to prevent replay attacks
  if (payload.nonce !== nonce) {
    throw new Error("Nonce mismatch — possible replay attack");
  }
 
  // Validate token expiry (jwtVerify handles this, but be explicit)
  const now = Math.floor(Date.now() / 1000);
  if ((payload.exp ?? 0) < now) {
    throw new Error("ID token expired");
  }
 
  // Parse and validate claims shape
  const claims = NafathClaimsSchema.parse(payload);
  return claims;
}

The national_id claim contains the user's Saudi National ID (رقم الهوية الوطنية) — a 10-digit number starting with 1 for Saudi nationals and 2 for residents (Iqama). You can use this to check against your own database, GOSI records, or any other system that uses the national ID as a key.

Step 7: Session Management

Store only what you need and never cache the access token client-side:

// lib/session.ts
import { SignJWT, jwtVerify } from "jose";
 
const SESSION_SECRET = new TextEncoder().encode(
  process.env.SESSION_SECRET!
);
 
export interface SessionPayload {
  nationalId: string;
  fullName: string;
  phone?: string;
  birthdate?: string;
  accessToken: string;
  expiresAt: number;
}
 
export async function createSession(payload: SessionPayload): Promise<string> {
  return new SignJWT(payload as Record<string, unknown>)
    .setProtectedHeader({ alg: "HS256" })
    .setIssuedAt()
    .setExpirationTime("8h")
    .sign(SESSION_SECRET);
}
 
export async function getSession(token: string): Promise<SessionPayload | null> {
  try {
    const { payload } = await jwtVerify(token, SESSION_SECRET);
    return payload as unknown as SessionPayload;
  } catch {
    return null;
  }
}

For production, use a server-side session store (Redis, database) rather than encoding everything in the cookie — especially if you need to invalidate sessions when a user logs out or when their Nafath account is suspended.

Step 8: Protect Routes

Create a middleware that enforces Nafath authentication:

// middleware.ts
import { NextRequest, NextResponse } from "next/server";
import { getSession } from "@/lib/session";
 
const PROTECTED_PATHS = ["/dashboard", "/account", "/services"];
 
export async function middleware(request: NextRequest) {
  const isProtected = PROTECTED_PATHS.some((path) =>
    request.nextUrl.pathname.startsWith(path)
  );
 
  if (!isProtected) return NextResponse.next();
 
  const sessionToken = request.cookies.get("session")?.value;
  if (!sessionToken) {
    return NextResponse.redirect(new URL("/auth/login", request.url));
  }
 
  const session = await getSession(sessionToken);
  if (!session) {
    const response = NextResponse.redirect(new URL("/auth/login", request.url));
    response.cookies.delete("session");
    return response;
  }
 
  // Check token hasn't expired on Nafath's side
  const now = Math.floor(Date.now() / 1000);
  if (session.expiresAt < now) {
    // Redirect to re-authenticate — handle refresh token separately
    return NextResponse.redirect(new URL("/auth/nafath", request.url));
  }
 
  return NextResponse.next();
}
 
export const config = {
  matcher: ["/dashboard/:path*", "/account/:path*", "/services/:path*"],
};

Step 9: The Keycloak Bridge Alternative

SDAIA published an open-source Keycloak Identity Provider plugin for Nafath at oss.dga.gov.sa. If your organization already runs Keycloak (common in larger enterprises and government agencies), this is the fastest path:

Architecture:

Your App --> Keycloak --> Nafath iDART --> User's Phone
         (OIDC/SAML)   (iDART plugin)

Setup:

# Download the Keycloak provider JAR from oss.dga.gov.sa
# Deploy to: $KEYCLOAK_HOME/providers/
 
# Keycloak realm configuration (via Admin Console)
# Identity Providers > Add Provider > Nafath
# Fill in: client_id, client_secret, iDART endpoints

From your TypeScript application, you then integrate with Keycloak (not Nafath directly) using standard OIDC. Keycloak handles the Nafath-specific protocol details and presents you with a normalized identity. This approach also gives you Keycloak's user management, session control, and audit logging on top.

Testing in the Sandbox

SDAIA provides a sandbox environment for development. In the sandbox:

  • Use test national IDs provided in the developer documentation
  • The Nafath mobile app has a "sandbox" switch — instruct your testers to enable it
  • Token lifetimes are shorter (5 minutes) to encourage proper refresh handling
  • Sandbox tokens cannot access production civil registry data

Testing the timeout edge case: simulate a user who ignores the phone notification. After 60 seconds, Nafath's authorization endpoint will return an error. Your callback handler should redirect gracefully:

// In your callback route, handle Nafath-specific errors
const errorCode = params.error;
const errorDescription = params.error_description;
 
if (errorCode === "access_denied") {
  // User rejected or the request timed out on their phone
  return NextResponse.redirect("/auth/error?reason=nafath_rejected");
}

Troubleshooting

"redirect_uri_mismatch" — The redirect URI in your authorization request must exactly match what was registered with SDAIA. Include the protocol (https://), domain, and path. A trailing slash difference is enough to fail.

"invalid_client" — Double-check your client_id and client_secret. For client_secret_basic auth method, these are sent as a Base64-encoded header, not in the request body.

"nonce_expired" — The user took too long to approve the Nafath request. Implement a countdown on your waiting screen and auto-redirect back to start after 90 seconds.

JWKS fetch failures — Cache the JWKS locally with a 24-hour TTL. Nafath's JWKS rotates periodically; if verification fails after a previously-working period, clear your JWKS cache and re-fetch.

"scope_not_approved" — You requested a scope (like national_id) that was not granted in your SDAIA application. Review your approved scope list in the developer portal.

Security Checklist

Before going to production, verify these are all in place:

  • PKCE enabled (code_challenge_method: "S256") — prevents authorization code interception
  • Nonce validated in the ID token — prevents replay attacks
  • State parameter validated — prevents CSRF on the callback
  • ID token signature verified against JWKS — never skip this
  • exp claim checked — reject expired tokens
  • Redirect URI is HTTPS only — Nafath rejects non-HTTPS URIs
  • Session cookies are httpOnly, secure, sameSite: "strict"
  • Access token never exposed to the browser — keep it server-side only
  • Token refresh handled before expiry — avoid mid-session authentication failures

If you are building compliance-driven Saudi platforms, these tutorials cover the other integration layers your application will likely need:

Next Steps

Once Nafath authentication is working:

  1. Add logout: Call the iDART end-session endpoint to invalidate the Nafath session, not just your local cookie
  2. Handle token refresh: Use the refresh token to extend sessions without re-authenticating
  3. Store the national ID as a foreign key: Standardize on sub or national_id as your primary identity anchor across services
  4. Audit logging: Log every authentication event with timestamp, national ID hash (not plaintext), and outcome — required for regulated platforms
  5. Consider Keycloak: If you have multiple applications that need Nafath, the Keycloak bridge avoids implementing this flow in every application individually

Conclusion

Nafath integration is genuinely worth the registration friction. When a user authenticates through Nafath, you have verified identity that no other login method in Saudi Arabia can match. The OAuth2/OIDC flow is standard — the only non-standard parts are the mobile push confirmation, the scope approval process with SDAIA, and the civil registry claims in the token.

The SERP for Arabic Nafath integration documentation is empty. If you are building Saudi-facing platforms, integrating Nafath before your competitors do is a meaningful technical differentiator.


Need help with the Nafath registration process or integration architecture? Our team has delivered KSA government platform integrations across ZATCA, GOSI, WPS, and national identity services. Contact us to scope your integration project.