Skip to main content

Command Palette

Search for a command to run...

How Clerk Saved Me Two Weeks

Updated
9 min read
How Clerk Saved Me Two Weeks
J

Full-Stack Developer with 3+ years of expertise in the JavaScript ecosystem (React, Next.js, TypeScript, Node.js). Currently operating as an independent developer, architecting and shipping scalable products including a global travel platform. I am passionate about building efficient, type-safe web architectures and actively expanding my expertise into Cloud Engineering with AWS, Docker, and Kubernetes.

One of the first decisions I made when starting Roavelo was not to build authentication from scratch.

Why Clerk

I needed to ship the MVP quickly. Building auth from scratch would take at least 2-3 weeks.

- JWT sign/verify logic
- Refresh token rotation
- Redis session management
- Password hashing + salt
- Email verification flow
- Password reset flow
- OAuth integration (Google, Apple)
- Device trust / suspicious login detection
- Error handling + tests
- Unexpected issues

This is just “it works” level. Not “passes security audit” level.

Authentication isn’t about “working correctly”—it’s about “not getting breached.”

const verifyPassword = async (input, stored) => {
  const [salt, hash] = stored.split(':');
  const inputHash = await scrypt(input, salt, 64);
  return timingSafeEqual(Buffer.from(hash, 'hex'), inputHash);
};

To prevent timing attacks, you need timingSafeEqual. How do you verify this is correctly implemented? Unless you’re a security expert, it’s hard to be certain.

Clerk’s team specializes in authentication. They have SOC 2 Type II certification. I decided security should be delegated to experts.

Migration Plan to JWT + Redis

Currently using Clerk, but I plan to migrate to JWT + Redis when MAU grows.

  • Latency: External API calls on every auth request introduce delay

  • Cost: Clerk costs scale with user growth

  • Customization: May need auth flows that Clerk doesn’t support

The strategy is to ship quickly with Clerk at the MVP stage, then migrate as the product scales.


Mobile: OAuth Implementation

Clerk’s strength is OAuth integration. Here’s the code for Google and Apple login:

// mobile/hooks/useSocialAuth.ts
export const useSocialAuth = () => {
  const { startSSOFlow } = useSSO();

  const handleSocialAuth = async (strategy: "oauth_google" | "oauth_apple") => {
    const { createdSessionId, setActive } = await startSSOFlow({ strategy });
    if (createdSessionId && setActive) {
      await setActive({ session: createdSessionId });
    }
  };

  return { handleSocialAuth };
};

OAuth URL generation, callback handling, and token exchange are all abstracted inside startSSOFlow.

One required setup:

// Completes the OAuth session when returning from browser
WebBrowser.maybeCompleteAuthSession();

Without this, auth works in the browser but doesn’t return to the app. Easy to miss with Expo + Clerk.

Email Authentication

Email login has more steps:

// mobile/hooks/useEmailSignUp.ts
const startSignUp = async (email: string, password: string) => {
  // 1. Create signup
  await signUp.create({
    emailAddress: email,
    password,
  });

  // 2. Request email verification code
  await signUp.prepareEmailAddressVerification({
    strategy: "email_code",
  });

  return { needsVerification: true };
};

const verifyEmail = async (code: string) => {
  // 3. Verify the code
  const result = await signUp.attemptEmailAddressVerification({ code });

  if (result.status === "complete") {
    // 4. Activate session
    await setActive({ session: result.createdSessionId });
    return true;
  }
  return false;
};

What you’d need to consider if building this yourself:

  • Code generation (6 digits? Numbers only? Include letters?)

  • Redis storage (5 minute TTL? 10?)

  • Email sending (SendGrid? SES? SMTP?)

  • Resend logic (60 second cooldown?)

  • Lockout on failure (lock after 3 tries?)

Clerk handles all of this. Just call prepareEmailAddressVerification.


Token Interceptor: Continuing from Blog #2

In my previous post, I mentioned token injection when setting up the API client. Here’s that code in detail.

// mobile/utils/api.ts
const configureApiClient = (api: AxiosInstance, getToken: GetTokenFn) => {
  // Auto-inject token on every request
  api.interceptors.request.use(async (config) => {
    const token = await getToken();
    if (!token) {
      return Promise.reject(new Error('AUTH_TOKEN_NOT_READY'));
    }
    config.headers.Authorization = `Bearer${token}`;
    return config;
  });

  // Retry with fresh token on 401
  api.interceptors.response.use(
    (response) => response,
    async (error: AxiosError) => {
      if (error.response?.status === 401 && !error.config._retry) {
        error.config._retry = true;

        // Skip cache, get fresh token
        const newToken = await getToken({ skipCache: true });
        if (newToken) {
          error.config.headers.Authorization = `Bearer${newToken}`;
          return api(error.config);
        }
      }
      return Promise.reject(error);
    }
  );
};

Why throw AUTH_TOKEN_NOT_READY:

When the app first launches, API calls may happen before Clerk SDK initializes. At this point, sending a 401 because there’s no token is incorrect—we don’t know the login state yet. Let React Query auto-retry, and a few milliseconds later the token will be ready.

The skipCache: true option skips Clerk’s internal token cache. If the server returned 401, the cached token is expired, so we request a fresh one bypassing the cache.


Backend: The Clerk ↔︎ MongoDB Sync Trap

When a user signs up, an account is created in Clerk. But we also need a User document in MongoDB. How do we handle this sync?

Attempt 1: Webhook Only (Failed)

Initially, I relied only on the user.created webhook. There was a problem:

1. User signs up (created in Clerk)
2. Webhook in transit...
3. User makes API call in app (no User document yet!)
4. 500 error

Webhooks are async. Not real-time.

Attempt 2: Upsert on First API Call (Current)

// backend/src/services/user.service.ts
export const upsertUser = async (clerkId: string) => {
  // Return immediately if exists
  const existingUser = await User.findOne({ clerkId });
  if (existingUser) {
    return { user: existingUser, isNew: false };
  }

  // Fetch user info from Clerk
  const clerkUser = await clerkClient.users.getUser(clerkId);

  // Generate unique username (email prefix + random suffix)
  const baseUsername = clerkUser.emailAddresses[0].emailAddress.split('@')[0];
  const randomSuffix = randomBytes(4).toString('hex');
  const username = `${baseUsername}_${randomSuffix}`;

  try {
    const user = await User.create({
      clerkId,
      email: clerkUser.emailAddresses[0].emailAddress,
      firstName: clerkUser.firstName || 'Unknown',
      lastName: clerkUser.lastName || '',
      username,
      profilePicture: clerkUser.imageUrl || '',
    });
    return { user, isNew: true };
  } catch (error) {
    // Rollback: delete Clerk account if MongoDB save fails
    await clerkClient.users.deleteUser(clerkId);
    throw error;
  }
};

The key is the rollback logic.

If MongoDB save fails (e.g., unique constraint violation on username), we delete the Clerk account. Otherwise you get “ghost accounts”—exists in Clerk but not in MongoDB.

I encountered this during testing. There was a bug in username generation that kept creating the same username, and Clerk ended up with 5 accounts while MongoDB had none.


Webhook: Svix Signature Verification

Webhooks come from outside. How do we verify they’re from Clerk?

Clerk uses Svix for webhook delivery. Every request includes a signature:

// backend/src/middleware/verifyClerkWebhook.ts
export const verifyClerkWebhook = (req: Request, res: Response, next: NextFunction) => {
  // Extract Svix signature headers
  const svixId = req.headers['svix-id'];
  const svixTimestamp = req.headers['svix-timestamp'];
  const svixSignature = req.headers['svix-signature'];

  if (!svixId || !svixTimestamp || !svixSignature) {
    return res.status(400).json({ error: 'Missing svix headers' });
  }

  // Need raw body (before JSON parsing)
  const payload = req.body as Buffer;

  const wh = new Webhook(ENV.CLERK_WEBHOOK_SECRET);

  try {
    const evt = wh.verify(payload, {
      'svix-id': svixId,
      'svix-timestamp': svixTimestamp,
      'svix-signature': svixSignature,
    });

    req.webhookEvent = evt;
    next();
  } catch (err) {
    return res.status(400).json({ error: 'Invalid signature' });
  }
};

The most important part:

// backend/src/server.ts

// Webhook route MUST be registered before express.json()!
app.post(
  '/api/v2/webhooks/clerk',
  express.raw({ type: 'application/json' }),  // keep raw body
  verifyClerkWebhook,
  handleClerkWebhook,
);

// Other routes use JSON parsing
app.use(express.json());

If express.json() runs first, the body becomes a JavaScript object. But Svix signatures are calculated against the raw body. That’s why only the webhook route uses express.raw().

Not knowing this order causes persistent “Invalid signature” errors. This took considerable debugging time.


user.deleted: Idempotency + GDPR

When a user deletes their account, the user.deleted webhook fires. But it doesn’t fire just once.

If webhook delivery fails, Clerk retries. The same event can arrive multiple times, so it must be handled with idempotency.

// backend/src/services/webhook.service.ts
export const handleUserDeleted = async (clerkId: string) => {
  // 1. Find user
  const user = await User.findOne({ clerkId }).lean();

  if (!user) {
    // Already deleted → duplicate webhook, ignore it
    logger.info(`user.deleted: already deleted`);
    return;  // Don't throw an error!
  }

  // 2. Archive before deletion (for GDPR/refunds)
  const purchases = await Purchase.find({ clerkId }).lean();

  await DeletedUser.create({
    clerkId,
    reason: 'user_requested',
    userData: user,      // backup original User document
    purchases,           // backup purchase history
  });

  // 3. Actual deletion (parallel)
  await Promise.all([
    User.deleteOne({ clerkId }),
    Purchase.deleteMany({ clerkId }),
    Wishlist.deleteOne({ _id: clerkId }),
  ]);

  // 4. Clean up follower/following relationships
  await User.updateMany(
    { $or: [{ followers: user._id }, { following: user._id }] },
    { $pull: { followers: user._id, following: user._id } }
  );
};

Why have a separate DeletedUser model:

  1. Refund inquiries: Can verify purchase history when users claim they made purchases

  2. GDPR compliance: Proof of deletion for European users

  3. Legal retention: Manually delete completely after retention period

// backend/src/models/deletedUser.model.ts
const deletedUserSchema = new Schema({
  clerkId: { type: String, required: true, index: true },
  deletedAt: { type: Date, default: Date.now, index: true },
  reason: {
    type: String,
    enum: ['user_requested', 'admin_action', 'policy_violation']
  },
  userData: { type: Schema.Types.Mixed, required: true },
  purchases: [{ type: Schema.Types.Mixed }],
  notes: { type: String },  // admin notes
});

Auth Guard: Delayed Action Execution for Guests

What happens when a logged-out user taps “Add to Wishlist”?

Option 1: Show “Please login” alert → Login → Find wishlist again → Add Option 2: Auto-add after login

Option 2 is better UX. This is what useAuthGuard implements:

// mobile/hooks/useAuthGuard.ts
export const useAuthGuard = () => {
  const { isSignedIn } = useAuth();
  const pendingCallbackRef = useRef<(() => void) | null>(null);

  // Execute saved callback when login succeeds
  useEffect(() => {
    if (isSignedIn && pendingCallbackRef.current) {
      setTimeout(() => {
        pendingCallbackRef.current?.();
        pendingCallbackRef.current = null;
      }, 500);  // wait for UI transition
    }
  }, [isSignedIn]);

  const guardedAction = (action: string, callback: () => void) => {
    if (isSignedIn) {
      // Already logged in - execute immediately
      callback();
    } else {
      // Not logged in - save callback + show login sheet
      pendingCallbackRef.current = callback;
      showLoginSheet(action);
    }
  };

  return { guardedAction };
};

Usage:

const { guardedAction } = useAuthGuard();

const handleAddWishlist = (countryId: string) => {
  guardedAction(
    'ADD_WISHLIST',
    () => addToWishlist(countryId)
  );
};

When a guest taps the button:

  1. Callback saved to pendingCallbackRef

  2. Login bottom sheet appears

  3. Login succeeds → isSignedIn becomes true

  4. useEffect triggers → saved callback executes

  5. Added to wishlist

The 500ms delay exists because executing the callback before UI transition completes feels awkward.


Auth-Based Routing

Redirecting based on auth state in Expo Router:

// mobile/app/(auth)/_layout.tsx
export default function AuthRoutesLayout() {
  const { isSignedIn } = useAuth();

  // Already logged in? Redirect to tabs
  if (isSignedIn) {
    return <Redirect href={'/(tabs)'} />;
  }

  // Not logged in? Show auth screens
  return <Stack screenOptions={{ headerShown: false }} />;
}

Looks simple, but without it:

  • Logged-in users typing /auth/email-auth directly see the login screen

  • Users can navigate back to auth screens

This Layout component acts as a gatekeeper.


Conclusion

At the MVP stage, authentication is not something to build yourself. Two weeks can be spent building core features instead.

What Clerk provides:

  • OAuth, email verification, password reset flows

  • SOC 2 Type II level security

  • Over two weeks of development time saved

I plan to migrate to JWT + Redis when MAU grows, but at this stage, Clerk was the right choice.

More from this blog

JungJun's Engineering Journal

6 posts

Documenting the journey of building scalable web applications. This publication focuses on React, TypeScript, Serverless architecture, and Cloud Engineering (AWS/Docker).