| name | clerk |
| description | Clerk authentication integration for Astro/Next.js. Use when implementing authentication, handling Clerk middleware, testing with Playwright, or debugging auth issues. Trigger phrases include "Clerk auth", "sign in", "authentication", "middleware", "E2E testing with Clerk". |
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.
await clerk.signIn({
page,
signInParams: { strategy: 'password', identifier: email, password }
});
The Solution: UI-Based Sign-In with Test Emails
Use actual UI sign-in flow with Clerk's +clerk_test email feature:
await page.goto(`/sign-in?__clerk_testing_token=${testingToken}`);
await page.fill('input[name="identifier"]', 'user+clerk_test@example.com');
await page.click('button:has-text("Continue")');
await page.fill('input[type="password"]', password);
await page.click('button:has-text("Continue")');
await enterVerificationCode(page, CLERK_TEST_VERIFICATION_CODE);
Test Email Magic Code (CRITICAL for E2E/CI)
ā ļø CRITICAL: Test user emails MUST contain +clerk_test for 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 ā (no clerk_test in address)
john_clerk_test@gmail.com ā (must use + plus-addressing)
clerktest@gmail.com ā (must use +clerk_test suffix 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();
Complete E2E Auth Setup
import { createClerkClient } from '@clerk/backend';
const clerkClient = createClerkClient({
secretKey: process.env.CLERK_SECRET_KEY,
});
async function authenticateUser(page, email, password, storagePath) {
const { token } = await clerkClient.testingTokens.createTestingToken();
await page.goto(`/sign-in?__clerk_testing_token=${token}`);
await page.fill('input[name="identifier"]', email);
await page.click('button:has-text("Continue")');
await page.fill('input[type="password"]', password);
await page.click('button:has-text("Continue")');
await page.waitForTimeout(2000);
if (page.url().includes('factor-two')) {
const code = process.env.CLERK_TEST_CODE;
const inputs = page.locator('input[inputmode="numeric"]');
for (let i = 0; i < 6; i++) {
await inputs.nth(i).fill(code[i]);
}
}
await page.waitForURL(url => !url.includes('/sign-in'));
await page.context().storageState({ path: storagePath });
}
Middleware Configuration
Basic Protected Routes
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;
}
if (!userId) {
return auth().redirectToSignIn();
}
});
Role-Based Access
export const onRequest = clerkMiddleware(async (auth, context) => {
const { userId } = auth();
if (!userId) {
return auth().redirectToSignIn();
}
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
---
const auth = Astro.locals.auth();
const { userId, sessionClaims } = auth;
if (!userId) {
return Astro.redirect('/sign-in');
}
const member = await memberQueries.findByClerkId(userId);
---
Client-Side Auth Check
<script>
function checkAuth() {
if (window.Clerk?.loaded && !window.Clerk.user) {
window.Clerk.redirectToSignIn({ redirectUrl: window.location.href });
}
}
const interval = setInterval(() => {
if (window.Clerk?.loaded) {
clearInterval(interval);
checkAuth();
}
}, 100);
</script>
Webhook Handling
import { Webhook } from 'svix';
export const POST: APIRoute = async ({ request }) => {
const payload = await request.text();
const headers = Object.fromEntries(request.headers);
const wh = new Webhook(process.env.CLERK_WEBHOOK_SECRET);
const event = wh.verify(payload, headers);
switch (event.type) {
case 'user.created':
break;
case 'user.updated':
break;
}
return new Response('OK', { status: 200 });
};
Troubleshooting
"Session not recognized by server"
Cause: Using @clerk/testing programmatic sign-in which only creates client-side sessions.
Fix: Use UI-based sign-in flow with testing tokens:
await page.goto(`/sign-in?__clerk_testing_token=${token}`);
"Bot traffic detected"
Cause: Clerk's bot protection blocking automated requests.
Fix: Include testing token in URL:
const token = await clerkClient.testingTokens.createTestingToken();
await page.goto(`/sign-in?__clerk_testing_token=${token.token}`);
"Device verification required"
Cause: Clerk requires email verification from new devices.
Fix: Use +clerk_test email suffix with Clerk's magic test code:
const email = 'user+clerk_test_admin@gmail.com';
Common mistake: Using emails like user+admin@gmail.com without clerk_test - the magic code won't work!
"redirectToSignIn not working"
Cause: Page is pre-rendered (SSG) so server-side redirect doesn't work.
Fix: Use client-side redirect:
export const prerender = true;
if (!window.Clerk?.user) {
window.Clerk?.redirectToSignIn();
}
Middleware Not Running
Cause: Route might be pre-rendered or middleware configuration issue.
Fix: Ensure SSR mode for protected routes:
export default defineConfig({
output: 'server',
});
CI/CD Integration
GitHub Actions Setup
- name: Run authenticated E2E tests
env:
CLERK_SECRET_KEY: ${{ secrets.TEST_CLERK_SECRET_KEY }}
TEST_ADMIN_EMAIL: ${{ secrets.TEST_ADMIN_EMAIL }}
TEST_ADMIN_PASSWORD: ${{ secrets.TEST_ADMIN_PASSWORD }}
run: npx playwright test
Secret Configuration Checklist
- ā
Create test users in Clerk with
+clerk_test emails
- ā
Set passwords for test users (Clerk Dashboard ā Users)
- ā
Store email/password pairs as GitHub Secrets
- ā
Verify emails contain
+clerk_test substring
- ā
Test locally before pushing to CI
Syncing Local to CI
If your local .env works but CI fails, sync your secrets:
source .env
gh secret set TEST_ADMIN_EMAIL --body "$TEST_ADMIN_EMAIL"
gh secret set TEST_ADMIN_PASSWORD --body "$TEST_ADMIN_PASSWORD"
Common CI Failure: "Verification code failed"
Symptom: Local tests pass, CI tests fail at device verification step.
Root Cause: GitHub Secrets have emails WITHOUT +clerk_test:
# ā Wrong - magic code won't work
TEST_ADMIN_EMAIL=user+admin@gmail.com
# ā
Correct - magic code will work
TEST_ADMIN_EMAIL=user+clerk_test_admin@gmail.com
Fix: Update GitHub Secrets with correctly formatted emails.
Environment Variables
# Required for Clerk
PUBLIC_CLERK_PUBLISHABLE_KEY=pk_test_xxx
CLERK_SECRET_KEY=sk_test_xxx
CLERK_WEBHOOK_SECRET=whsec_xxx
# For E2E testing - MUST contain +clerk_test
TEST_ADMIN_EMAIL=user+clerk_test_admin@gmail.com
TEST_ADMIN_PASSWORD=xxx
TEST_MEMBER_EMAIL=user+clerk_test_member@gmail.com
TEST_MEMBER_PASSWORD=xxx
Package Reference
| Package | Purpose |
|---|
@clerk/astro | Astro integration (components, middleware) |
@clerk/backend | Server-side operations (testing tokens, user management) |
@clerk/testing | Test utilities (limited - client-side only) |
svix | Webhook signature verification |
References
Last updated: December 22, 2025
Added: CI/CD integration patterns and +clerk_test email requirements