Clerk Authentication Skill
Comprehensive guide for implementing and testing Clerk authentication, with special focus on Astro SSR integration and Playwright E2E testing.
Key Concepts
Clerk Architecture in Astro
┌─────────────────────────────────────────────────────────────┐
│ Browser (Client) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ Clerk Frontend SDK (@clerk/astro) │ │
│ │ - Manages client-side session state │ │
│ │ - Provides <SignIn>, <UserButton> components │ │
│ │ - Sets localStorage tokens │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
│
▼
┌─────────────────────────────────────────────────────────────┐
│ Server (Astro SSR) │
│ ┌─────────────────────────────────────────────────────┐ │
│ │ clerkMiddleware (@clerk/astro/server) │ │
│ │ - Validates HTTPOnly session cookies │ │
│ │ - Runs BEFORE any custom middleware logic │ │
│ │ - Sets Astro.locals.auth() │ │
│ └─────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
Critical Understanding: Clerk's middleware validates sessions at the wrapper level BEFORE your callback executes. You cannot bypass authentication inside the middleware callback.
Session Types
| Session Type | Created By | Server Validated | Use Case |
|---|---|---|---|
| HTTPOnly Cookie | UI sign-in flow | ✅ Yes | Production, E2E tests |
| Client-side | @clerk/testing signIn() | ❌ No | Unit tests only |
| Backend API | sessions.create() | ⚠️ Partial | Limited use |
E2E Testing with Playwright
The Problem
@clerk/testing's programmatic clerk.signIn() creates client-side sessions only. These are NOT recognized by Clerk's server-side middleware in Astro/Next.js SSR applications.
// ❌ This creates client-side session only - won't pass middleware
await clerk.signIn({
page,
signInParams: { strategy: 'password', identifier: email, password }
});
// User appears logged in (UserButton shows), but server redirects to /sign-in
The Solution: UI-Based Sign-In with Test Emails
Use actual UI sign-in flow with Clerk's +clerk_test email feature:
// ✅ This creates real server-validated session
// 1. Navigate to sign-in with testing token (bypasses bot detection)
await page.goto(`/sign-in?__clerk_testing_token=${testingToken}`);
// 2. Fill in email (MUST contain +clerk_test)
await page.fill('input[name="identifier"]', 'user+clerk_test@example.com');
await page.click('button:has-text("Continue")');
// 3. Fill in password
await page.fill('input[type="password"]', password);
await page.click('button:has-text("Continue")');
// 4. Handle device verification with magic code
// See: clerk.com/docs/guides/development/testing/test-emails-and-phones
await enterVerificationCode(page, CLERK_TEST_VERIFICATION_CODE);
Test Email Magic Code (CRITICAL for E2E/CI)
⚠️ CRITICAL: Test user emails MUST contain
+clerk_testfor automated testing to work. Without this suffix, Clerk requires real email verification which breaks CI/CD pipelines.
Any email with +clerk_test suffix is treated specially by Clerk:
- No actual email sent for verification
- Clerk's magic test code always works for any verification step
- Real users unaffected - normal verification for non-test emails
- Works in both development and production Clerk instances
Valid test email formats:
john+clerk_test@gmail.com✅test+clerk_test_admin@example.com✅user+clerk_test_member@company.com✅
Invalid for automated testing:
john+admin@gmail.com❌ (noclerk_testin address)john_clerk_test@gmail.com❌ (must use+plus-addressing)clerktest@gmail.com❌ (must use+clerk_testsuffix format)
Get the verification code: See Clerk's Test Emails Documentation for the magic verification code that works with +clerk_test emails.
💡 CI/CD Tip: Store test user emails in environment variables/secrets. Ensure all contain
+clerk_test:TEST_ADMIN_EMAIL=user+clerk_test_admin@gmail.com TEST_MEMBER_EMAIL=user+clerk_test_member@gmail.com
Testing Token
Get a testing token to bypass bot detection:
import { createClerkClient } from '@clerk/backend';
const clerkClient = createClerkClient({
secretKey: process.env.CLERK_SECRET_KEY,
});
const token = await clerkClient.testingTokens.createTestingToken();
// Use as: /sign-in?__clerk_testing_token=${token.token}
Complete E2E Auth Setup
// tests/e2e/global-setup.ts
import { createClerkClient } from '@clerk/backend';
const clerkClient = createClerkClient({
secretKey: process.env.CLERK_SECRET_KEY,
});
async function authenticateUser(page, email, password, storagePath) {
// 1. Get testing token
const { token } = await clerkClient.testingTokens.createTestingToken();
// 2. Navigate with token
await page.goto(`/sign-in?__clerk_testing_token=${token}`);
// 3. Fill email (must have +clerk_test)
await page.fill('input[name="identifier"]', email);
await page.click('button:has-text("Continue")');
// 4. Fill password
await page.fill('input[type="password"]', password);
await page.click('button:has-text("Continue")');
// 5. Handle device verification (code from Clerk docs)
// See: clerk.com/docs/guides/development/testing/test-emails-and-phones
await page.waitForTimeout(2000);
if (page.url().includes('factor-two')) {
const code = process.env.CLERK_TEST_CODE; // From Clerk docs
const inputs = page.locator('input[inputmode="numeric"]');
for (let i = 0; i < 6; i++) {
await inputs.nth(i).fill(code[i]);
}
}
// 6. Wait for redirect and save session
await page.waitForURL(url => !url.includes('/sign-in'));
await page.context().storageState({ path: storagePath });
}
Middleware Configuration
Basic Protected Routes
// src/middleware.ts
import { clerkMiddleware, createRouteMatcher } from "@clerk/astro/server";
const isPublicRoute = createRouteMatcher([
"/",
"/sign-in(.*)",
"/sign-up(.*)",
"/api/webhooks/(.*)",
]);
export const onRequest = clerkMiddleware((auth, context) => {
const { userId } = auth();
if (isPublicRoute(context.request)) {
return; // Allow public routes
}
if (!userId) {
return auth().redirectToSignIn();
}
});
Role-Based Access
// Check role inside middleware callback
export const onRequest = clerkMiddleware(async (auth, context) => {
const { userId } = auth();
if (!userId) {
return auth().redirectToSignIn();
}
// Check admin routes
if (context.request.url.includes('/admin')) {
const member = await memberQueries.findByClerkId(userId);
if (member?.role !== 'admin') {
return context.redirect('/unauthorized');
}
}
});
Common Patterns
Get Current User in Astro Pages
// src/pages/dashboard.astro
---
const auth = Astro.locals.auth();
const { userId, sessionClaims } = auth;
if (!userId) {
return Astro.redirect('/sign-in');
}
// Get user data from your database
const member = await memberQueries.findByClerkId(userId);
---
Client-Side Auth Check
// For pre-rendered pages that need client-side auth
<script>
function checkAuth() {
if (window.Clerk?.loaded && !window.Clerk.user) {
window.Clerk.redirectToSignIn({ redirectUrl: window.location.href });
}
}
// Poll until Clerk loads
const interval = setInte