Custom OAuth: Google & Apple Login Without Auth Services

Jul 19, 2026

8 min read

title image

As an indie dev, every dollar counts. Auth0 starts at $23/month, Clerk at $25/month. For my project Sharry, I implemented Google & Apple OAuth myself. Here's how.

Why Custom Instead of SaaS?

The usual arguments for auth services are:

That's true - if you want to validate quickly. But as a solo indie dev with multiple projects, costs add up. $25/month per project? That's $300/year before you earn a single cent.

My reasons for custom OAuth:

  1. Cost: $0 instead of $25/month
  2. Control: No vendor lock-in, no dependencies
  3. Learning: Understanding OAuth makes you a better developer
  4. Simplicity: For social login, you don't need a complete auth platform

Google OAuth: The Easy Part

Google OAuth is straightforward. You need:

  1. A project in the Google Cloud Console
  2. OAuth 2.0 Credentials (Client ID + Secret)
  3. Configure redirect URI

The Flow

// Generate OAuth URL
export function getGoogleAuthUrl(): string {
  const state = generateState("google"); // CSRF Protection
  const params = new URLSearchParams({
    client_id: GOOGLE_CLIENT_ID,
    redirect_uri: GOOGLE_REDIRECT_URI,
    response_type: "code",
    scope: "openid email profile",
    state,
    access_type: "offline",
    prompt: "select_account",
  });
 
  return `https://accounts.google.com/o/oauth2/v2/auth?${params}`;
}

The state parameter is important for CSRF protection. Generate a random string, store it server-side, and validate it on callback:

const pendingStates = new Map<
  string,
  { provider: string; createdAt: number }
>();
 
function generateState(provider: string): string {
  const state = crypto.randomUUID();
  pendingStates.set(state, { provider, createdAt: Date.now() });
  return state;
}
 
function validateState(state: string, expectedProvider: string): boolean {
  const data = pendingStates.get(state);
  if (!data || data.provider !== expectedProvider) {
    return false;
  }
  pendingStates.delete(state);
  return true;
}

Exchange Code for Tokens

After the redirect, you receive a code that you exchange for tokens:

async function exchangeGoogleCode(
  code: string,
): Promise<GoogleTokenResponse | null> {
  const response = await fetch("https://oauth2.googleapis.com/token", {
    method: "POST",
    headers: { "Content-Type": "application/x-www-form-urlencoded" },
    body: new URLSearchParams({
      client_id: GOOGLE_CLIENT_ID,
      client_secret: GOOGLE_CLIENT_SECRET,
      code,
      grant_type: "authorization_code",
      redirect_uri: GOOGLE_REDIRECT_URI,
    }),
  });
 
  if (!response.ok) return null;
  return response.json();
}

Fetch User Info

With the access_token, you fetch user info:

async function getGoogleUserInfo(
  accessToken: string,
): Promise<GoogleUserInfo | null> {
  const response = await fetch(
    "https://www.googleapis.com/oauth2/v2/userinfo",
    {
      headers: { Authorization: `Bearer ${accessToken}` },
    },
  );
 
  if (!response.ok) return null;
  return response.json();
}

That's it. Google OAuth in ~50 lines of code.

Apple OAuth: The Complicated Part

Apple doesn't make it easy. While Google uses a simple client secret, Apple expects a JWT as client secret that you sign with your private key.

What You Need

  1. Apple Developer Account ($99/year - you probably have it for iOS anyway)
  2. App ID with "Sign in with Apple" capability
  3. Services ID (this is your client ID)
  4. Private Key (.p8 file)

The JWT Client Secret

Apple's client secret is a JWT that you generate fresh for each token request:

async function generateAppleClientSecret(): Promise<string> {
  const header = {
    alg: "ES256",
    kid: APPLE_KEY_ID,
    typ: "JWT",
  };
 
  const now = Math.floor(Date.now() / 1000);
  const payload = {
    iss: APPLE_TEAM_ID,
    iat: now,
    exp: now + 86400 * 180, // Valid for 180 days
    aud: "https://appleid.apple.com",
    sub: APPLE_CLIENT_ID,
  };
 
  const base64url = (str: string) =>
    btoa(str).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
 
  const encodedHeader = base64url(JSON.stringify(header));
  const encodedPayload = base64url(JSON.stringify(payload));
  const signatureInput = `${encodedHeader}.${encodedPayload}`;
 
  // Import private key (convert PEM to DER)
  const privateKeyPem = APPLE_PRIVATE_KEY.replace(/\\n/g, "\n");
  const privateKeyDer = pemToDer(privateKeyPem);
 
  const cryptoKey = await crypto.subtle.importKey(
    "pkcs8",
    privateKeyDer,
    { name: "ECDSA", namedCurve: "P-256" },
    false,
    ["sign"],
  );
 
  // Sign JWT with ES256 (ECDSA P-256)
  const signatureBuffer = await crypto.subtle.sign(
    { name: "ECDSA", hash: "SHA-256" },
    cryptoKey,
    new TextEncoder().encode(signatureInput),
  );
 
  // Convert signature from DER to raw format for JWT
  const signature = derToRaw(new Uint8Array(signatureBuffer));
  const encodedSignature = base64url(
    String.fromCharCode(...new Uint8Array(signature)),
  );
 
  return `${signatureInput}.${encodedSignature}`;
}

Don't skip this conversion. WebCrypto's ECDSA signatures are supposed to be in raw IEEE P1363 format (r || s), but depending on the runtime you can end up with a DER-encoded signature instead. A JWT with the wrong format gets silently rejected by Apple, so derToRaw checks for the DER marker and converts if needed:

function derToRaw(der: Uint8Array): Uint8Array {
  if (der[0] !== 0x30) {
    // Already in raw format
    return der;
  }
 
  let offset = 2;
  if (der[1] === 0x81) {
    offset = 3;
  }
 
  // Skip first integer marker and length
  offset++; // 02
  const rLen = der[offset++];
  const r = der.slice(offset, offset + rLen);
  offset += rLen;
 
  // Skip second integer marker and length
  offset++; // 02
  const sLen = der[offset++];
  const s = der.slice(offset, offset + sLen);
 
  // Normalize to 32 bytes each
  const rNorm = normalizeInt(r, 32);
  const sNorm = normalizeInt(s, 32);
 
  const raw = new Uint8Array(64);
  raw.set(rNorm, 0);
  raw.set(sNorm, 32);
 
  return raw;
}
 
function normalizeInt(int: Uint8Array, length: number): Uint8Array {
  if (int.length === length) {
    return int;
  }
  if (int.length > length) {
    // Remove leading zeros
    return int.slice(int.length - length);
  }
  // Pad with leading zeros
  const padded = new Uint8Array(length);
  padded.set(int, length - int.length);
  return padded;
}

Apple's Quirks

1. form_post Response Mode

Apple sends the callback as POST, not GET. Your server needs to handle that:

// Apple sends: POST /auth/callback/apple
// Body: code=xxx&state=xxx&user={"name":{"firstName":"Max"}}

2. Name Only on First Login

The user's name only comes with the very first authorization request. Never again after that. Store it immediately:

// userInfo only comes the first time
const userInfo = req.body.user ? JSON.parse(req.body.user) : undefined;

3. Email in ID Token

The email is embedded in the JWT id_token, not in a separate API:

function decodeJwtPayload<T>(jwt: string): T | null {
  const parts = jwt.split(".");
  if (parts.length !== 3) return null;
 
  const base64 = parts[1].replace(/-/g, "+").replace(/_/g, "/");
  const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
  return JSON.parse(atob(padded));
}
 
// Usage
const idToken = decodeJwtPayload<AppleIdTokenPayload>(tokens.id_token);
const email = idToken.email;

The Complete Apple Callback

export async function handleAppleCallback(
  code: string,
  state: string,
  userInfo?: {
    name?: { firstName?: string; lastName?: string };
    email?: string;
  },
): Promise<{ user: User; sessionToken: string } | { error: string }> {
  // Validate state (CSRF)
  if (!validateState(state, "apple")) {
    return { error: "Invalid state parameter" };
  }
 
  // Exchange code for tokens (with JWT as client secret)
  const tokens = await exchangeAppleCode(code);
  if (!tokens) {
    return { error: "Failed to exchange code for tokens" };
  }
 
  // Decode ID token for user info
  const idToken = decodeJwtPayload<AppleIdTokenPayload>(tokens.id_token);
  if (!idToken || !idToken.sub) {
    return { error: "Failed to decode ID token" };
  }
 
  // Email from ID token OR form POST (only first time)
  const email = idToken.email || userInfo?.email;
  if (!email) {
    return { error: "Email not provided" };
  }
 
  // Build name (only available on first login)
  let name: string | undefined;
  if (userInfo?.name) {
    const parts = [userInfo.name.firstName, userInfo.name.lastName].filter(
      Boolean,
    );
    name = parts.join(" ") || undefined;
  }
 
  // Create/find user and generate session token
  const user = findOrCreateUser({
    email,
    name,
    provider: "apple",
    provider_id: idToken.sub,
  });
 
  const sessionToken = createAuthSession(user.id);
  return { user, sessionToken };
}

Apple OAuth is definitely more work, but doable. Expect 2-3 hours to set it up.

Session Management

After successful OAuth, you create a session. I use simple bearer tokens:

export function createAuthSession(userId: string): string {
  const token = crypto.randomUUID();
  // Store token in DB with expiry
  db.run(
    "INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
    [token, userId, Date.now() + 30 * 24 * 60 * 60 * 1000], // 30 days
  );
  return token;
}
 
export function validateAuthSession(token: string): User | null {
  const session = db.get(
    "SELECT * FROM sessions WHERE token = ? AND expires_at > ?",
    [token, Date.now()],
  );
  if (!session) return null;
  return db.get("SELECT * FROM users WHERE id = ?", [session.user_id]);
}

Conclusion

Custom OAuth isn't for everyone. If you want to validate quickly, use Clerk or Auth0. But as an indie dev, the initial investment pays off.

Google OAuth: Easy. No excuse not to do it yourself.

Apple OAuth: Annoying because of JWT signing, but a good learning experience. The WebCrypto API is powerful.

Time investment: ~4-6 hours for both providers, one-time.

Savings: ~$300/year per project.

The code runs in production at Sharry. Questions? Find me on X @pr0gstar.