home / skills / mjunaidca / mjs-agent-skills / better-auth-sso
This skill integrates Next.js apps with Better Auth SSO for PKCE OAuth2 and OIDC flows, token management, and global logout.
npx playbooks add skill mjunaidca/mjs-agent-skills --skill better-auth-ssoReview the files below or copy the command above to add this skill to your agents.
---
name: better-auth-sso
description: Integrate with Better Auth SSO for OAuth2/OIDC authentication. Use this skill when implementing SSO login flows, PKCE authentication, token management, JWKS verification, or global logout in Next.js applications connecting to a Better Auth server.
---
# Better Auth SSO Integration
Integrate Next.js applications with Better Auth SSO using OAuth 2.1 / OIDC with PKCE flow.
## When to Use
- Implementing SSO login in Next.js apps
- Setting up PKCE-based OAuth flow (public clients)
- Managing tokens in httpOnly cookies
- Verifying JWTs using JWKS
- Implementing global logout across apps
## Architecture Overview
```
┌─────────────────┐
│ Better Auth SSO │ ← Central auth server
│ (Auth Server) │
└────────┬────────┘
│
┌────┴────┐
▼ ▼
┌───────┐ ┌───────┐
│ App 1 │ │ App 2 │ ← Tenant apps (SSO clients)
└───────┘ └───────┘
```
## Quick Start
```bash
# Dependencies
npm install jose
# Environment
NEXT_PUBLIC_SSO_URL=http://localhost:3001
NEXT_PUBLIC_SSO_CLIENT_ID=your-client-id
```
## Core Patterns
### 1. PKCE Authentication Client
```typescript
// lib/auth-client.ts
const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL!;
const CLIENT_ID = process.env.NEXT_PUBLIC_SSO_CLIENT_ID!;
// Base64URL encoding helper
function base64UrlEncode(buffer: Uint8Array): string {
const base64 = btoa(String.fromCharCode(...buffer));
return base64.replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
}
// Generate cryptographically secure code verifier
export function generateCodeVerifier(): string {
const array = new Uint8Array(32);
crypto.getRandomValues(array);
return base64UrlEncode(array);
}
// Generate SHA-256 code challenge
export async function generateCodeChallenge(verifier: string): Promise<string> {
const encoder = new TextEncoder();
const data = encoder.encode(verifier);
const hash = await crypto.subtle.digest('SHA-256', data);
return base64UrlEncode(new Uint8Array(hash));
}
// Build OAuth authorization URL with PKCE
export async function getOAuthAuthorizationUrl(
callbackUrl?: string
): Promise<string> {
const codeVerifier = generateCodeVerifier();
const codeChallenge = await generateCodeChallenge(codeVerifier);
const state = generateCodeVerifier(); // Random state
// Store for token exchange
sessionStorage.setItem('pkce_code_verifier', codeVerifier);
sessionStorage.setItem('oauth_state', state);
if (callbackUrl) {
sessionStorage.setItem('oauth_callback_url', callbackUrl);
}
const params = new URLSearchParams({
client_id: CLIENT_ID,
redirect_uri: `${window.location.origin}/api/auth/callback`,
response_type: 'code',
scope: 'openid profile email',
state,
code_challenge: codeChallenge,
code_challenge_method: 'S256',
});
return `${SSO_URL}/api/auth/oauth2/authorize?${params}`;
}
// Get stored PKCE verifier
export function getStoredCodeVerifier(): string | null {
return sessionStorage.getItem('pkce_code_verifier');
}
// Clear PKCE storage
export function clearPKCEStorage(): void {
sessionStorage.removeItem('pkce_code_verifier');
sessionStorage.removeItem('oauth_state');
sessionStorage.removeItem('oauth_callback_url');
}
```
### 2. OAuth Callback Route
```typescript
// app/api/auth/callback/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL!;
const CLIENT_ID = process.env.NEXT_PUBLIC_SSO_CLIENT_ID!;
export async function GET(request: NextRequest) {
const searchParams = request.nextUrl.searchParams;
const code = searchParams.get('code');
const state = searchParams.get('state');
const error = searchParams.get('error');
if (error) {
return NextResponse.redirect(new URL(`/login?error=${error}`, request.url));
}
if (!code) {
return NextResponse.redirect(new URL('/login?error=no_code', request.url));
}
// Get code verifier from cookie (set by client before redirect)
const cookieStore = await cookies();
const codeVerifier = cookieStore.get('pkce_code_verifier')?.value;
if (!codeVerifier) {
return NextResponse.redirect(
new URL('/login?error=no_verifier', request.url)
);
}
try {
// Exchange code for tokens (PKCE - no client secret!)
const tokenResponse = await fetch(`${SSO_URL}/api/auth/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'authorization_code',
code,
redirect_uri: `${new URL(request.url).origin}/api/auth/callback`,
client_id: CLIENT_ID,
code_verifier: codeVerifier,
}),
});
if (!tokenResponse.ok) {
const error = await tokenResponse.text();
console.error('Token exchange failed:', error);
return NextResponse.redirect(
new URL('/login?error=token_exchange', request.url)
);
}
const tokens = await tokenResponse.json();
// Create response with redirect
const callbackUrl = cookieStore.get('oauth_callback_url')?.value || '/dashboard';
const response = NextResponse.redirect(new URL(callbackUrl, request.url));
// Set httpOnly cookies for tokens
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
};
response.cookies.set('access_token', tokens.access_token, {
...cookieOptions,
maxAge: 60 * 60 * 6, // 6 hours
});
response.cookies.set('refresh_token', tokens.refresh_token, {
...cookieOptions,
maxAge: 60 * 60 * 24 * 7, // 7 days
});
response.cookies.set('id_token', tokens.id_token, {
...cookieOptions,
maxAge: 60 * 60 * 6, // 6 hours
});
// Clear PKCE cookies
response.cookies.delete('pkce_code_verifier');
response.cookies.delete('oauth_state');
response.cookies.delete('oauth_callback_url');
return response;
} catch (error) {
console.error('Callback error:', error);
return NextResponse.redirect(
new URL('/login?error=callback_failed', request.url)
);
}
}
```
### 3. Session Route
```typescript
// app/api/auth/session/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { jwtVerify, createRemoteJWKSet } from 'jose';
const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL!;
const JWKS = createRemoteJWKSet(new URL(`${SSO_URL}/api/auth/jwks`));
export async function GET() {
const cookieStore = await cookies();
const idToken = cookieStore.get('id_token')?.value;
if (!idToken) {
return NextResponse.json({ user: null });
}
try {
const { payload } = await jwtVerify(idToken, JWKS, {
algorithms: ['RS256'],
});
return NextResponse.json({
user: {
id: payload.sub,
email: payload.email,
name: payload.name,
role: payload.role || 'user',
emailVerified: payload.email_verified,
},
expires: new Date((payload.exp as number) * 1000).toISOString(),
});
} catch (error) {
// Token invalid or expired
return NextResponse.json({ user: null });
}
}
```
### 4. Auth Context Provider
```typescript
// contexts/AuthContext.tsx
'use client';
import React, { createContext, useContext, useEffect, useState, useCallback } from 'react';
import { getOAuthAuthorizationUrl, clearPKCEStorage } from '@/lib/auth-client';
interface User {
id: string;
email: string;
name: string;
role: string;
emailVerified: boolean;
}
interface Session {
user: User | null;
expires?: string;
}
interface AuthContextValue {
session: Session | null;
status: 'loading' | 'authenticated' | 'unauthenticated';
signIn: (options?: { callbackUrl?: string }) => Promise<void>;
signOut: (options?: { redirect?: boolean }) => Promise<void>;
update: () => Promise<void>;
}
const AuthContext = createContext<AuthContextValue | null>(null);
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [session, setSession] = useState<Session | null>(null);
const [status, setStatus] = useState<'loading' | 'authenticated' | 'unauthenticated'>('loading');
const fetchSession = useCallback(async () => {
try {
const response = await fetch('/api/auth/session');
const data = await response.json();
if (data.user) {
setSession(data);
setStatus('authenticated');
} else {
setSession(null);
setStatus('unauthenticated');
}
} catch (error) {
console.error('Failed to fetch session:', error);
setSession(null);
setStatus('unauthenticated');
}
}, []);
useEffect(() => {
fetchSession();
}, [fetchSession]);
const signIn = async (options?: { callbackUrl?: string }) => {
const callbackUrl = options?.callbackUrl || window.location.pathname;
// Store callback URL in cookie for server-side access
document.cookie = `oauth_callback_url=${encodeURIComponent(callbackUrl)}; path=/; max-age=300`;
const authUrl = await getOAuthAuthorizationUrl(callbackUrl);
// Store code verifier in cookie for server-side access
const codeVerifier = sessionStorage.getItem('pkce_code_verifier');
if (codeVerifier) {
document.cookie = `pkce_code_verifier=${codeVerifier}; path=/; max-age=300`;
}
window.location.href = authUrl;
};
const signOut = async (options?: { redirect?: boolean }) => {
const { redirect = true } = options || {};
try {
const response = await fetch('/api/auth/signout', {
method: 'POST',
credentials: 'include',
});
const data = await response.json();
clearPKCEStorage();
if (redirect && data.redirectUrl) {
window.location.href = data.redirectUrl;
} else {
setSession(null);
setStatus('unauthenticated');
}
} catch (error) {
console.error('Sign out error:', error);
if (redirect) {
window.location.href = '/';
}
}
};
return (
<AuthContext.Provider value={{ session, status, signIn, signOut, update: fetchSession }}>
{children}
</AuthContext.Provider>
);
}
export function useSession() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useSession must be used within AuthProvider');
}
return {
data: context.session,
status: context.status,
update: context.update,
};
}
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used within AuthProvider');
}
return context;
}
```
### 5. Global Logout
```typescript
// app/api/auth/signout/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL;
export async function POST(request: NextRequest) {
const cookieStore = await cookies();
const idToken = cookieStore.get('id_token')?.value;
// Build SSO logout URL
const appUrl = new URL(request.url).origin;
const postLogoutRedirectUri = `${appUrl}/logged-out`;
let redirectUrl = '/logged-out';
if (SSO_URL) {
const endsessionUrl = new URL('/api/auth/oauth2/endsession', SSO_URL);
endsessionUrl.searchParams.set('post_logout_redirect_uri', postLogoutRedirectUri);
if (idToken) {
endsessionUrl.searchParams.set('id_token_hint', idToken);
}
redirectUrl = endsessionUrl.toString();
}
const response = NextResponse.json({ success: true, redirectUrl });
// Clear all auth cookies
response.cookies.delete('access_token');
response.cookies.delete('refresh_token');
response.cookies.delete('id_token');
return response;
}
```
### 6. Proxy Protection (Next.js 16)
```typescript
// proxy.ts (Next.js 16 - replaces middleware.ts)
import { NextRequest, NextResponse } from 'next/server';
import { jwtVerify, createRemoteJWKSet } from 'jose';
const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL!;
const JWKS = createRemoteJWKSet(new URL(`${SSO_URL}/api/auth/jwks`));
const publicPaths = ['/', '/login', '/register', '/api/auth', '/logged-out'];
async function isTokenValid(token: string): Promise<boolean> {
try {
await jwtVerify(token, JWKS, { algorithms: ['RS256'] });
return true;
} catch {
return false;
}
}
export async function proxy(request: NextRequest) {
const { pathname } = request.nextUrl;
// Allow public paths
if (publicPaths.some(p => pathname === p || pathname.startsWith(`${p}/`))) {
return NextResponse.next();
}
// Allow static assets
if (pathname.startsWith('/_next') || pathname.includes('.')) {
return NextResponse.next();
}
// Check for valid token
const idToken = request.cookies.get('id_token')?.value;
const refreshToken = request.cookies.get('refresh_token')?.value;
if (!idToken) {
return NextResponse.redirect(new URL('/login', request.url));
}
const isValid = await isTokenValid(idToken);
if (!isValid && refreshToken) {
// Redirect to refresh endpoint
const refreshUrl = new URL('/api/auth/refresh', request.url);
refreshUrl.searchParams.set('returnTo', pathname);
return NextResponse.redirect(refreshUrl);
}
if (!isValid) {
return NextResponse.redirect(new URL('/login', request.url));
}
return NextResponse.next();
}
export const config = {
matcher: ['/((?!_next/static|_next/image|favicon.ico).*)'],
};
```
### 7. Token Refresh
```typescript
// app/api/auth/refresh/route.ts
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const SSO_URL = process.env.NEXT_PUBLIC_SSO_URL!;
const CLIENT_ID = process.env.NEXT_PUBLIC_SSO_CLIENT_ID!;
export async function GET(request: NextRequest) {
const returnTo = request.nextUrl.searchParams.get('returnTo') || '/dashboard';
const cookieStore = await cookies();
const refreshToken = cookieStore.get('refresh_token')?.value;
if (!refreshToken) {
return NextResponse.redirect(new URL('/login', request.url));
}
try {
const tokenResponse = await fetch(`${SSO_URL}/api/auth/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});
if (!tokenResponse.ok) {
return NextResponse.redirect(new URL('/login', request.url));
}
const tokens = await tokenResponse.json();
const response = NextResponse.redirect(new URL(returnTo, request.url));
const cookieOptions = {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax' as const,
path: '/',
};
response.cookies.set('access_token', tokens.access_token, {
...cookieOptions,
maxAge: 60 * 60 * 6,
});
if (tokens.id_token) {
response.cookies.set('id_token', tokens.id_token, {
...cookieOptions,
maxAge: 60 * 60 * 6,
});
}
if (tokens.refresh_token) {
response.cookies.set('refresh_token', tokens.refresh_token, {
...cookieOptions,
maxAge: 60 * 60 * 24 * 7,
});
}
return response;
} catch (error) {
console.error('Token refresh failed:', error);
return NextResponse.redirect(new URL('/login', request.url));
}
}
export async function POST() {
const cookieStore = await cookies();
const refreshToken = cookieStore.get('refresh_token')?.value;
if (!refreshToken) {
return NextResponse.json({ error: 'No refresh token' }, { status: 401 });
}
try {
const tokenResponse = await fetch(`${SSO_URL}/api/auth/oauth2/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
body: new URLSearchParams({
grant_type: 'refresh_token',
refresh_token: refreshToken,
client_id: CLIENT_ID,
}),
});
if (!tokenResponse.ok) {
return NextResponse.json({ error: 'Refresh failed' }, { status: 401 });
}
const tokens = await tokenResponse.json();
const response = NextResponse.json({ success: true });
// Update cookies with new tokens
// ... same cookie setting logic
return response;
} catch (error) {
return NextResponse.json({ error: 'Refresh error' }, { status: 500 });
}
}
```
## Environment Variables
```env
# Public (exposed to client)
NEXT_PUBLIC_SSO_URL=http://localhost:3001
NEXT_PUBLIC_SSO_CLIENT_ID=your-client-id
# Private (server-side only)
SSO_JWKS_URL=http://localhost:3001/api/auth/jwks
```
## Security Checklist
- [ ] PKCE uses `crypto.getRandomValues()` for code verifier
- [ ] State parameter is random and verified
- [ ] Cookies are httpOnly, Secure (prod), SameSite=Lax
- [ ] No tokens in localStorage
- [ ] No tokens exposed in API responses
- [ ] Token refresh is server-side only
- [ ] Global logout clears SSO session
- [ ] Error messages don't leak secrets
## Common Pitfalls
### 1. "code verification failed" Error
**Cause**: PKCE code_verifier lost during sign-in redirect
**Fix**: Store in cookie before redirect, not just sessionStorage
### 2. Auto-Login After Logout
**Cause**: Only cleared local tokens, not SSO session
**Fix**: Redirect to SSO `/endsession` endpoint
### 3. Token Not Refreshing
**Cause**: Refresh happening client-side (blocked by httpOnly)
**Fix**: Use server-side API route for refresh
## References
For backend integration, see:
- `fastapi-backend/references/jwt-verification.md`
- `fastapi-backend/references/better-auth-sso-integration.md`
Better Auth docs: https://www.better-auth.com/docs
This skill integrates Next.js applications with Better Auth SSO using OAuth2/OIDC and PKCE. It provides reusable patterns for initiating PKCE flows, exchanging tokens, storing tokens in httpOnly cookies, verifying JWTs via JWKS, refreshing tokens, and performing global logout. Use it to implement secure SSO login and session management across tenant apps.
The skill offers client helpers to generate PKCE code verifiers and build the authorization URL, server routes to exchange authorization codes for tokens and set httpOnly cookies, and endpoints to verify id_tokens against the SSO JWKS. It includes a session route that decodes and validates id_tokens, a refresh endpoint to exchange refresh_tokens for new tokens, and a signout flow that clears cookies and optionally redirects to the central logout endpoint. A proxy/middleware pattern enforces protected routes by validating tokens and sending users to refresh or login when needed.
Do I need a client secret for PKCE flows?
No. With PKCE public clients (single-page / browser apps) do not use a client secret; use the code_verifier to complete the token exchange.
Where should tokens be stored?
Store access, refresh, and id tokens in httpOnly, secure cookies set from server responses to reduce XSS risk; do not store tokens in localStorage.