Claude Code with Stytch: Passwordless Auth Done Right
Why Stytch without CLAUDE.md ships authentication you can phish
Stytch is a developer-first authentication platform with first-class support for magic links, OAuth, WebAuthn passkeys, SMS OTP, and B2B SSO. The SDK is well-typed, the docs are accurate, and a working magic link flow can be assembled in an afternoon. The problem is that "working" and "secure" are two different bars.
By default, Claude Code generates Stytch integrations that authenticate users correctly in the happy path and quietly create attack surface in the edge cases. The most dangerous patterns: hardcoding the magic link redirect URL on the client side (where an attacker can spoof it), trusting the session token from a cookie without validating its JWT signature, mixing the Consumer API and B2B API in the same project, storing the Stytch project secret in client-accessible code, and skipping the email verification step on social OAuth flows that need explicit verification.
The deeper issue: authentication code looks correct when it works. Phishing-resistant authentication only proves its worth in adversarial conditions. Claude has no way to know which fields are security-critical without explicit instruction. A redirect URL that "works" in development can be a credential-stealing redirect in production if the allowlist is missing.
This guide covers the CLAUDE.md configuration that locks Claude Code into Stytch's correct security model: server-side token validation, redirect URL allowlisting, project secret hygiene, session JWT verification, and the patterns that block the most common Stytch mistakes. For the request lifecycle these auth checks plug into, Claude Code with Next.js covers route handlers and middleware. If you also need an alternative auth comparison, Claude Code with Clerk walks through Clerk's session model.
The Stytch CLAUDE.md template
# Stytch authentication rules
## Stack
- @stytch/nextjs ^14.x or @stytch/vanilla-js for client
- stytch ^11.x (Node SDK) for server
- Stytch Consumer Authentication API (NOT B2B) for this project
- TypeScript 5.x strict mode
## Project structure
- src/lib/stytch.ts Stytch server client singleton
- src/lib/session.ts Session JWT verification helper
- src/app/api/auth/ Route handlers for auth callbacks
- src/middleware.ts Next.js middleware for protected routes
## Environment (NEVER commit these)
- STYTCH_PROJECT_ID Server only, never exposed to client
- STYTCH_SECRET Server only, never exposed to client
- NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN Client-safe, prefixed correctly
## Project type lock-in
- This project uses Stytch Consumer Authentication ONLY
- NEVER mix Consumer and B2B API calls in the same project
- B2B Stytch uses different endpoints, different session models, different SDK clients
- If multi-tenant SaaS is required, switch the ENTIRE project to B2B
## Hard rules
- ALL token authentication happens server-side, NEVER trust client claims
- ALL magic link redirect URLs MUST be in the Stytch dashboard allowlist
- ALL session JWTs MUST be verified against Stytch's JWKS endpoint
- NEVER expose STYTCH_SECRET to any client-accessible code
- NEVER skip session JWT verification because "the cookie was httpOnly"
- NEVER pass user-controlled redirect URLs to authenticate()
- ALWAYS rotate STYTCH_SECRET if it leaks (Stytch dashboard, Project Settings)
- ALWAYS validate the session JWT exp and nbf claims yourself if you cache
The redirect URL allowlist rule prevents a well-documented phishing vector. If your magic link handler accepts a redirect_to query parameter and passes it to stytch.magicLinks.authenticate() without checking, an attacker can craft a magic link that authenticates the victim and then redirects to a phishing page. Stytch's dashboard allowlist is the right enforcement point. The CLAUDE.md rule reminds Claude to check it.
The JWT verification rule prevents trust-on-cookie attacks. A session JWT stored in an httpOnly cookie is encrypted in transit and not readable by client JavaScript, but the cookie value itself is still attacker-controllable in a CSRF or session-fixation scenario. Verifying the JWT signature against Stytch's JWKS endpoint on every request is the only safe pattern.
The Consumer vs B2B lock-in rule prevents architectural drift. Stytch maintains two parallel API surfaces. Their endpoints, session models, and SDK clients differ. A project that starts with stytch.magicLinks.email.loginOrCreate() (Consumer) and adds stytch.b2b.organizations.create() (B2B) ends up with two incompatible session types that cannot be reconciled. Lock in one API at the start.
Server client setup
Install the Stytch Node SDK:
npm i stytch
Add environment variables to .env.local:
STYTCH_PROJECT_ID=project-live-abc123-xyz
STYTCH_SECRET=secret-live-PASTE_FROM_DASHBOARD
NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN=public-token-abc123-xyz
The naming matters. STYTCH_PROJECT_ID and STYTCH_SECRET are server-only. NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN is the only Stytch credential that should ever reach the client, and it is scoped at the dashboard to only what the public token can do (initiate magic links, but not authenticate them).
Create the server client singleton:
// src/lib/stytch.ts
import * as stytch from 'stytch';
if (!process.env.STYTCH_PROJECT_ID || !process.env.STYTCH_SECRET) {
throw new Error('STYTCH_PROJECT_ID and STYTCH_SECRET must be set');
}
export const stytchClient = new stytch.Client({
project_id: process.env.STYTCH_PROJECT_ID,
secret: process.env.STYTCH_SECRET,
env: process.env.NODE_ENV === 'production'
? stytch.envs.live
: stytch.envs.test,
});
The env selection matters. Stytch maintains separate test and live environments, each with its own project ID and secret. Mixing them silently fails at runtime because authentication calls succeed on test but the production frontend cannot resolve the resulting session against live.
Add the singleton rule to CLAUDE.md:
## Server client singleton (ENFORCE)
- The only Stytch server client lives at src/lib/stytch.ts
- Every server file imports: import { stytchClient } from '@/lib/stytch'
- Claude MUST NOT instantiate new stytch.Client() inline anywhere else
- env field MUST match NODE_ENV (test for dev, live for production)
Magic link flow
Magic link authentication splits across two endpoints: one to send the link, one to authenticate the token when the user clicks it. Both need to be server-side.
The send endpoint:
// src/app/api/auth/magic-link/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { stytchClient } from '@/lib/stytch';
const ALLOWED_REDIRECTS = new Set([
'https://claudify.tech/dashboard',
'https://claudify.tech/welcome',
]);
export async function POST(req: NextRequest) {
const { email, redirectTo } = await req.json();
if (typeof email !== 'string' || !email.includes('@')) {
return NextResponse.json({ error: 'Invalid email' }, { status: 400 });
}
const target = ALLOWED_REDIRECTS.has(redirectTo)
? redirectTo
: 'https://claudify.tech/dashboard';
try {
await stytchClient.magicLinks.email.loginOrCreate({
email,
login_magic_link_url: target,
signup_magic_link_url: target,
});
return NextResponse.json({ ok: true });
} catch (err) {
console.error('[Stytch] magic link send failed:', err);
return NextResponse.json({ error: 'Failed to send' }, { status: 500 });
}
}
Two things to notice. First, the ALLOWED_REDIRECTS set is the application-side allowlist. Stytch also enforces an allowlist at the dashboard level, but defence in depth is correct here. Second, the response is { ok: true } regardless of whether the email exists in Stytch's user pool. This is intentional. Returning a different response for "email exists" vs "email new" creates an enumeration oracle that attackers use to discover valid accounts.
The authenticate endpoint that handles the magic link click:
// src/app/api/auth/authenticate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stytchClient } from '@/lib/stytch';
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.redirect(new URL('/auth/error', req.url));
}
try {
const result = await stytchClient.magicLinks.authenticate({
token,
session_duration_minutes: 60 * 24 * 7,
});
cookies().set('stytch_session_jwt', result.session_jwt, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
return NextResponse.redirect(new URL('/dashboard', req.url));
} catch (err) {
console.error('[Stytch] authenticate failed:', err);
return NextResponse.redirect(new URL('/auth/error', req.url));
}
}
The cookie attributes are non-negotiable. httpOnly prevents JavaScript access. secure requires HTTPS. sameSite: 'lax' blocks CSRF on POST requests. Any of these missing turns the session cookie into a stealable credential.
Add the magic link rules to CLAUDE.md:
## Magic link flow
Send endpoint (server-side):
- ALWAYS validate email format before calling Stytch
- ALWAYS check redirectTo against an allowlist
- ALWAYS return the same response for known and unknown emails (no enumeration)
- NEVER expose the result of loginOrCreate to the client
Authenticate endpoint (server-side):
- Set session JWT in httpOnly, secure, sameSite=lax cookie
- session_duration_minutes: pick a sensible default (7 days = 10080)
- Redirect to a fixed URL after authentication, NOT a user-controlled URL
- On failure, redirect to /auth/error, do NOT echo the error to the user
Session JWT verification
Every request to a protected route needs to verify the session JWT. Claude defaults to reading the cookie and trusting its contents. The correct pattern verifies the signature against Stytch's JWKS endpoint and checks the claims.
// src/lib/session.ts
import { jwtVerify, createRemoteJWKSet } from 'jose';
const STYTCH_JWKS_URL = process.env.NODE_ENV === 'production'
? `https://api.stytch.com/v1/sessions/jwks/${process.env.STYTCH_PROJECT_ID}`
: `https://test.stytch.com/v1/sessions/jwks/${process.env.STYTCH_PROJECT_ID}`;
const JWKS = createRemoteJWKSet(new URL(STYTCH_JWKS_URL));
export interface VerifiedSession {
userId: string;
sessionId: string;
expiresAt: Date;
}
export async function verifySessionJWT(jwt: string): Promise<VerifiedSession | null> {
try {
const { payload } = await jwtVerify(jwt, JWKS, {
issuer: `stytch.com/${process.env.STYTCH_PROJECT_ID}`,
audience: process.env.STYTCH_PROJECT_ID,
});
const stytchClaims = payload['https://stytch.com/session'] as {
user_id: string;
session_id: string;
expires_at: string;
};
return {
userId: stytchClaims.user_id,
sessionId: stytchClaims.session_id,
expiresAt: new Date(stytchClaims.expires_at),
};
} catch (err) {
console.error('[Stytch] JWT verification failed:', err);
return null;
}
}
The verification process:
- Fetch the JWKS (JSON Web Key Set) from Stytch. This contains the public keys Stytch uses to sign session JWTs.
createRemoteJWKSetcaches the keys with appropriate TTL. - Verify the JWT signature using the matching public key.
- Check the
issuerandaudienceclaims to confirm this JWT was issued for your project, not someone else's. - Extract the Stytch-specific claims (user ID, session ID, expiration).
If any step fails, return null. Never partially trust a session JWT.
Use the verifier in middleware:
// src/middleware.ts
import { NextRequest, NextResponse } from 'next/server';
import { verifySessionJWT } from '@/lib/session';
export async function middleware(req: NextRequest) {
const jwt = req.cookies.get('stytch_session_jwt')?.value;
if (!jwt) {
return NextResponse.redirect(new URL('/login', req.url));
}
const session = await verifySessionJWT(jwt);
if (!session) {
const response = NextResponse.redirect(new URL('/login', req.url));
response.cookies.delete('stytch_session_jwt');
return response;
}
if (session.expiresAt < new Date()) {
const response = NextResponse.redirect(new URL('/login', req.url));
response.cookies.delete('stytch_session_jwt');
return response;
}
const headers = new Headers(req.headers);
headers.set('x-user-id', session.userId);
headers.set('x-session-id', session.sessionId);
return NextResponse.next({ request: { headers } });
}
export const config = {
matcher: ['/dashboard/:path*', '/account/:path*', '/api/protected/:path*'],
};
Add JWT verification to CLAUDE.md:
## Session JWT verification (MANDATORY on every protected request)
- Use jose library, createRemoteJWKSet for the Stytch JWKS endpoint
- Verify issuer: stytch.com/${STYTCH_PROJECT_ID}
- Verify audience: ${STYTCH_PROJECT_ID}
- Check Stytch session claim expires_at against current time
- On any verification failure: clear the cookie, redirect to login
- NEVER read the cookie and trust the user_id claim without signature verification
- Use in middleware.ts for route protection
- Inject x-user-id header for route handlers downstream
OAuth flow
Stytch wraps Google, GitHub, Apple, Microsoft, and other OAuth providers behind a uniform API. The flow: client redirects to Stytch, Stytch redirects to the provider, provider redirects back to Stytch, Stytch redirects to your application with a token.
Initiate the flow:
// Client-side initiation (in a button onClick handler)
const stytchPublicToken = process.env.NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN!;
const oauthLoginUrl =
`https://api.stytch.com/v1/public/oauth/google/start` +
`?public_token=${stytchPublicToken}` +
`&login_redirect_url=${encodeURIComponent('https://claudify.tech/api/auth/oauth/authenticate')}` +
`&signup_redirect_url=${encodeURIComponent('https://claudify.tech/api/auth/oauth/authenticate')}`;
window.location.href = oauthLoginUrl;
Authenticate the OAuth callback:
// src/app/api/auth/oauth/authenticate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stytchClient } from '@/lib/stytch';
export async function GET(req: NextRequest) {
const token = req.nextUrl.searchParams.get('token');
if (!token) {
return NextResponse.redirect(new URL('/auth/error', req.url));
}
try {
const result = await stytchClient.oauth.authenticate({
token,
session_duration_minutes: 60 * 24 * 7,
});
cookies().set('stytch_session_jwt', result.session_jwt, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
return NextResponse.redirect(new URL('/dashboard', req.url));
} catch (err) {
console.error('[Stytch] OAuth authenticate failed:', err);
return NextResponse.redirect(new URL('/auth/error', req.url));
}
}
The same pattern as magic link authentication. The session cookie is set with the same attributes. The redirect target is fixed, not user-controlled.
Add OAuth to CLAUDE.md:
## OAuth flow
- Stytch OAuth Start URL: https://api.stytch.com/v1/public/oauth/{provider}/start
- public_token is the NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN (client-safe)
- login_redirect_url and signup_redirect_url MUST be in the Stytch dashboard allowlist
- Callback handler uses the SAME pattern as magic link authenticate
- Same cookie attributes: httpOnly, secure, sameSite=lax
- For Google specifically: enable Google OAuth in Stytch dashboard, paste OAuth credentials
MFA enrolment with WebAuthn
WebAuthn passkeys provide phishing-resistant MFA. The enrolment flow runs on the client and binds a passkey to the authenticated session. The verification flow validates the passkey on subsequent logins.
// Client-side enrolment after the user is already authenticated
import { StytchUIClient } from '@stytch/vanilla-js';
const client = new StytchUIClient(process.env.NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN!);
async function enrollPasskey() {
try {
const result = await client.webauthn.register({
domain: window.location.hostname,
});
console.log('Passkey registered:', result.user_id);
} catch (err) {
console.error('Passkey registration failed:', err);
}
}
Server-side verification on subsequent login:
// src/app/api/auth/webauthn/authenticate/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { stytchClient } from '@/lib/stytch';
export async function POST(req: NextRequest) {
const { credential } = await req.json();
try {
const result = await stytchClient.webauthn.authenticate({
public_key_credential: credential,
session_duration_minutes: 60 * 24 * 7,
});
cookies().set('stytch_session_jwt', result.session_jwt, {
httpOnly: true,
secure: true,
sameSite: 'lax',
path: '/',
maxAge: 60 * 60 * 24 * 7,
});
return NextResponse.json({ ok: true });
} catch (err) {
console.error('[Stytch] WebAuthn authenticate failed:', err);
return NextResponse.json({ error: 'Failed' }, { status: 401 });
}
}
WebAuthn requires HTTPS even in development (localhost is the only exception). The browser also requires the domain to match exactly, so be careful about subdomain handling in development environments.
Common Claude Code mistakes with Stytch
Six patterns Claude generates incorrectly without CLAUDE.md constraints, with the correct replacement for each.
1. Trusting the session cookie without JWT verification
Claude generates: const userId = JSON.parse(decodeURIComponent(cookie.value)).user_id.
Correct pattern: const session = await verifySessionJWT(cookie.value) against Stytch's JWKS.
2. User-controlled redirect URLs
Claude generates: await stytch.magicLinks.email.loginOrCreate({ email, login_magic_link_url: req.body.redirectTo }).
Correct pattern: check redirectTo against an application-side allowlist, fall back to a safe default.
3. Exposing STYTCH_SECRET in NEXT_PUBLIC_ vars
Claude generates: NEXT_PUBLIC_STYTCH_SECRET=... because the naming pattern matches.
Correct pattern: STYTCH_SECRET=... (no prefix), and only NEXT_PUBLIC_STYTCH_PUBLIC_TOKEN=... is exposed to the client.
4. Mixing Consumer and B2B
Claude generates: stytchClient.b2b.organizations.create() in a project that already uses stytchClient.magicLinks.email.loginOrCreate().
Correct pattern: pick Consumer OR B2B at project start, lock it in the CLAUDE.md, never mix.
5. Returning enumeration-friendly responses
Claude generates: if (userExists) return { exists: true } else return { exists: false } on the send endpoint.
Correct pattern: always return { ok: true } regardless of whether the email exists.
6. Skipping session expiration check
Claude generates: JWT verification but no check of expires_at.
Correct pattern: after JWT signature verification, also check session.expiresAt > new Date() and clear the cookie if expired.
Add these as concrete before/after examples in CLAUDE.md.
Permission hooks for auth scripts
A Stytch project accumulates scripts: user list exports, session revocation utilities, secret rotation scripts. Some are read-only. Some are destructive. Gate the destructive ones.
In .claude/settings.local.json:
{
"permissions": {
"allow": [
"Bash(node scripts/list-users.js*)",
"Bash(node scripts/check-session.js*)",
"Bash(node scripts/test-magic-link.js*)"
],
"deny": [
"Bash(node scripts/revoke-all-sessions.js*)",
"Bash(node scripts/delete-user.js*)",
"Bash(node scripts/rotate-secret.js*)"
]
}
}
For broader hardening principles around handling auth secrets and cookies in production, Claude Code best practices covers the secret rotation patterns that apply to Stytch and every other auth provider.
Building Stytch integrations that pass review
The Stytch CLAUDE.md in this guide produces auth integrations where session JWTs are verified against the JWKS on every protected request, redirect URLs are allowlisted at both Stytch and application layers, the project secret never reaches client-accessible code, cookies use the full httpOnly + secure + sameSite hardening, and Consumer vs B2B is locked in from the start.
The underlying principle: authentication looks correct when it works for the developer testing it. Phishing resistance and credential security only matter when someone is trying to break in. Claude has no signal about adversarial conditions without explicit CLAUDE.md rules. Every rule you skip becomes a known vulnerability the next time someone audits your auth.
For the broader auth landscape, Claude Code with Better Auth covers an open-source alternative if you prefer to own the session model, and Claudify includes a Stytch-specific CLAUDE.md template with magic link flows, OAuth wiring, JWT verification, WebAuthn enrolment, and all six common-mistake rules pre-configured.
Get Claudify. Ship Stytch auth that holds up under adversarial review.
More like this
Ready to upgrade your Claude Code setup?
Get Claudify