— 8 min read

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.
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:
Google OAuth is straightforward. You need:
// 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;
}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();
}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 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.
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;
}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;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.
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]);
}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.