Skip to content
better-auth-sso logo

Better Auth SSO Integration

better-auth-sso

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.

SKILL.md

Full skill instructions

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

# 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

// 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

// 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

// 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

// 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

// 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)

// 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

// 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

# 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

More skills from majiayu000

xiaohongshu logo
majiayu000/claude-arsenal

xiaohongshu

xiaohongshu

286 148
View
agent-task-conductor logo
majiayu000/claude-skill-registry

agent-task-conductor

Conduct multi-agent task orchestration and workflow coordination.

663 1
View
conductor-setup logo
majiayu000/claude-skill-registry

conductor-setup

Initialize project with Conductor artifacts (product definition,

663 1
View
animation-designer logo
majiayu000/claude-skill-registry

animation-designer

Expert in web animations, transitions, and motion design using Framer Motion and CSS

663 1
View
diagramming logo
majiayu000/claude-skill-registry

diagramming

Creates Mermaid and ASCII diagrams for flowcharts, architecture, ERDs, state machines, mindmaps, and more. Use when user mentions diagram, flowchart, mermaid, ASCII diagram, text diagram, terminal diagram, visualize, C4, mindmap, architecture diagram, sequence diagram, ERD, or needs visual docume...

663 1
View
h3-pg logo
majiayu000/claude-skill-registry-data

h3-pg

PostgreSQL bindings for H3 hexagonal grid system. Use when working with H3 cells in Postgres, including spatial indexing, geometry/geography integration, and raster analysis.

23 1
View
conductor-development logo
majiayu000/claude-skill-registry

conductor-development

Context-Driven Development skill for projects using Conductor. Use this skill when you detect a `conductor/` directory in the project, when working on tasks defined in a `plan.md` file, or when the user asks about tracks, specs, or plans. Automatically applies TDD workflow, tracks task completion...

663 1
View
conductor-status logo
majiayu000/claude-skill-registry

conductor-status

Display project status, active tracks, and next actions

663 1
View
dockerization logo
majiayu000/claude-skill-registry

dockerization

Official Stakpak application containerization standard operating procedure, a step-by-step guidline to properly dockerize applications. This is a rule book curated by the Stakpak Team.

663 1
View

Popular AI tools

Kaiber logo
Video

Kaiber

Generate, edit, and beat-sync AI video with leading models in one workspace.

Paid
View
Vimcal logo
Productivity

Vimcal

The world's fastest calendar for remote work

Free
View

Transform Your Design with AI Designer by ImgCreator.ai

Freemium
View
Akool AI logo
Content & writing

Akool AI

Revolutionizing Video Production with AI-Powered Creativity

Paid
View

Extend an image past the frame and let AI fill the new aspect ratio.

Freemium
View
StarByFace logo
Security

StarByFace

Discover your celebrity doppelgänger with StarByFace!

Free
View
C

ChainClarity explains 700+ crypto whitepapers in plain English, with layered summaries, comparisons, research tools, alerts, and a $4.99 Pro plan.

Freemium
View
Opus Clip logo
Coding & apps

Opus Clip

Opus.ai: Revolutionize Your Web Experience

Free
View