OAuth 2.0 + PKCE
Delegate authentication to AuthSaas via a secure redirect flow — no password handling in your app.
Overview#
AuthSaas implements the Authorization Code Flow with PKCE (RFC 7636). Your application redirects the user to a hosted AuthSaas login page; after the user authenticates, AuthSaas redirects back to your redirect_uri with a short-lived authorization code. Your server exchanges the code for access and refresh tokens.
Your application never handles passwords. All credential validation happens on the AuthSaas server.
When to use OAuth vs. direct login#
| Scenario | Recommended approach |
|---|---|
| You want a hosted login UI with no extra dev work | OAuth + PKCE ← this page |
| You need a fully custom-branded login form | Direct SDK login |
| Your app is an SPA with no backend | Direct SDK login |
| Multiple apps share the same user session | OAuth + PKCE (SSO) |
| Strict password isolation requirement | OAuth + PKCE |
PKCE flow — step by step#
The full flow involves five steps. Steps 1 and 3–4 run in your application; step 2 is handled entirely by AuthSaas.
1 · Start the flow (your app)#
Generate a random code_verifier, derive a code_challenge from it (SHA-256 → base64url), and build an authorization URL. Save the verifier and a random state token in short-lived cookies for CSRF protection.
import { NextRequest, NextResponse } from 'next/server';
import crypto from 'crypto';
const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL!.trim(); // e.g. https://auth.royalda.com
const CLIENT_ID = process.env.AUTH_CLIENT_ID!;
const APP_URL = process.env.NEXT_PUBLIC_APP_URL!.trim();
function generateVerifier(): string {
return crypto.randomBytes(40).toString('base64url');
}
function deriveChallenge(verifier: string): string {
return crypto.createHash('sha256').update(verifier).digest('base64url');
}
export async function GET(req: NextRequest) {
const verifier = generateVerifier();
const challenge = deriveChallenge(verifier);
const state = crypto.randomBytes(16).toString('hex');
const redirectUri = `${APP_URL}/api/auth/callback`;
const url = new URL(`${AUTH_URL}/api/v1/oauth/authorize`);
url.searchParams.set('response_type', 'code');
url.searchParams.set('client_id', CLIENT_ID);
url.searchParams.set('redirect_uri', redirectUri);
url.searchParams.set('code_challenge', challenge);
url.searchParams.set('code_challenge_method', 'S256');
url.searchParams.set('state', state);
const res = NextResponse.redirect(url.toString());
const cookieBase = `Path=/; HttpOnly; SameSite=Lax; Max-Age=300${
process.env.NODE_ENV === 'production' ? '; Secure' : ''
}`;
res.headers.append('Set-Cookie', `oauth_code_verifier=${verifier}; ${cookieBase}`);
res.headers.append('Set-Cookie', `oauth_state=${state}; ${cookieBase}`);
return res;
}2 · Hosted login page (AuthSaas)#
AuthSaas validates the request parameters and redirects the user to /oauth/login. The user enters their credentials on the AuthSaas-hosted form. On success, AuthSaas redirects back to your redirect_uri with ?code=xxx&state=xxx.
ℹ Note
3 · Handle the callback (your app)#
Your callback route verifies the state, then hands the code and code_verifier to the token exchange. Set cookies in a 200 HTML response — cookie headers on 302 redirects are unreliable in Next.js route handlers.
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const AUTH_URL = process.env.NEXT_PUBLIC_AUTH_URL!.trim();
const CLIENT_ID = process.env.AUTH_CLIENT_ID!;
const APP_URL = process.env.NEXT_PUBLIC_APP_URL!.trim();
export async function GET(req: NextRequest) {
const { searchParams } = req.nextUrl;
const code = searchParams.get('code');
const state = searchParams.get('state');
const jar = await cookies();
const savedState = jar.get('oauth_state')?.value;
const codeVerifier = jar.get('oauth_code_verifier')?.value;
if (!code || !state || state !== savedState || !codeVerifier) {
return NextResponse.redirect(`${APP_URL}?auth_error=invalid_state`);
}
// Exchange code for tokens (server-to-server)
const tokenRes = await fetch(`${AUTH_URL}/api/v1/oauth/token`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
grant_type: 'authorization_code',
code,
client_id: CLIENT_ID,
code_verifier: codeVerifier,
redirect_uri: `${APP_URL}/api/auth/callback`,
}),
});
if (!tokenRes.ok) {
return NextResponse.redirect(`${APP_URL}?auth_error=token_exchange_failed`);
}
const { data } = await tokenRes.json();
const { access_token, refresh_token } = data;
// Return 200 HTML + explicit Set-Cookie headers (more reliable than 302 + cookies)
const isProduction = process.env.NODE_ENV === 'production';
const secure = isProduction ? '; Secure' : '';
const html = `<!DOCTYPE html><html><head>
<meta http-equiv="refresh" content="0;url=${APP_URL}">
<title>Signing in...</title>
</head><body></body></html>`;
const res = new NextResponse(html, {
status: 200,
headers: { 'Content-Type': 'text/html; charset=utf-8' },
});
// httpOnly refresh token — readable only by /api routes
res.headers.append('Set-Cookie',
`refresh_token=${refresh_token}; HttpOnly; SameSite=Lax; Path=/api; Max-Age=604800${secure}`);
// Short-lived readable cookie — consumed once on client init
res.headers.append('Set-Cookie',
`_at_init=${encodeURIComponent(access_token)}; SameSite=Lax; Path=/; Max-Age=30${secure}`);
// Clear PKCE cookies
res.headers.append('Set-Cookie', 'oauth_state=; Path=/; Max-Age=0');
res.headers.append('Set-Cookie', 'oauth_code_verifier=; Path=/; Max-Age=0');
return res;
}4 · Exchange the code (server-to-server)#
The POST /api/v1/oauth/token call in step 3 is a server-to-server request — it must never be called directly from the browser. The endpoint has no CORS headers. Sending the code_verifier proves possession of the original challenge without needing a client secret.
5 · Store tokens (your client)#
Read the _at_init cookie on first load, store the access token in sessionStorage, then clear the cookie. For subsequent page loads, use therefresh endpoint to silently obtain a new access token from the httpOnlyrefresh_token cookie.
// Called once on app init to consume the _at_init cookie
export function consumeInitToken(): string | null {
const match = document.cookie.match(/(?:^|; )_at_init=([^;]*)/);
if (!match) return null;
const token = decodeURIComponent(match[1]);
// Clear the cookie immediately
document.cookie = '_at_init=; Path=/; Max-Age=0';
return token;
}
// Silent refresh via your own proxy route
export async function refreshAccessToken(): Promise<string | null> {
const res = await fetch('/api/auth/refresh', { method: 'POST' });
if (!res.ok) return null;
const { data } = await res.json();
return data.accessToken ?? null;
}⚠ Warning
localStorage — XSS attacks can steal them. Use sessionStorage for the access token and an httpOnly cookie for the refresh token.Endpoint reference#
GET /oauth/authorize#
Validates OAuth parameters and redirects to the hosted login page. Called directly by your login-start route via browser redirect — not via fetch.
client_id string required Your app's clientId
redirect_uri string required Must match an allowedOrigin for the app
response_type string required Must be "code"
code_challenge string required BASE64URL(SHA256(code_verifier))
code_challenge_method string required Must be "S256"
state string required Random CSRF token — echo back to your callback302 → /oauth/login?client_id=...&redirect_uri=...&code_challenge=...&state=...{ "success": false, "error": "redirect_uri not allowed", "code": "INVALID_REDIRECT_URI" }POST /oauth/authorize#
Authenticates user credentials and issues an authorization code. Called by the AuthSaas hosted login form — you do not call this directly.
{
"client_id": "your_client_id",
"email": "user@example.com",
"password": "secret",
"code_challenge": "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM",
"code_challenge_method": "S256",
"redirect_uri": "https://your-app.com/api/auth/callback",
"state": "a1b2c3d4e5f6"
}{
"success": true,
"data": {
"redirectTo": "https://your-app.com/api/auth/callback?code=abc123&state=a1b2c3d4e5f6"
}
}ℹ Note
data.redirectTo and navigates via window.location.href. This sidesteps the opaqueredirect fetch limitation.POST /oauth/token#
Exchanges an authorization code for access and refresh tokens. Server-to-server only — no CORS headers, never call from a browser.
{
"grant_type": "authorization_code",
"code": "abc123",
"client_id": "your_client_id",
"code_verifier": "dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk",
"redirect_uri": "https://your-app.com/api/auth/callback"
}{
"success": true,
"data": {
"access_token": "eyJ...",
"refresh_token": "eyJ...",
"token_type": "Bearer",
"expires_in": 900
}
}The refresh token is also set as an httpOnly cookie (Path=/api/v1/auth, 7 days) for server-side use.
INVALID_GRANT 400 Code not found, already used, expired, or verifier mismatch
INVALID_CLIENT 401 client_id mismatch
UNSUPPORTED_GRANT_TYPE 400 grant_type must be authorization_codeSecurity notes#
- PKCE replaces client secrets for public clients. The
code_verifieris never transmitted until step 4, so a stolen code alone cannot be exchanged. - State prevents CSRF. Always verify that the
statein the callback matches the cookie you set in step 1. - Authorization codes expire in 10 minutes and are single-use. Replaying a used code returns
INVALID_GRANT. - redirect_uri validation — only URIs that start with an
allowedOriginconfigured for your app (orlocalhostduring development) are accepted. - Keep
POST /oauth/tokenserver-side. It has no CORS headers intentionally to prevent browser access.
Next.js full example#
The complete reference implementation (login-start, callback, refresh, logout) is available in the TaskFlow open-source project under app/api/auth/.
app/
api/
auth/
login-start/route.ts ← Step 1: generate PKCE params, redirect to AuthSaas
callback/route.ts ← Step 3: verify state, exchange code, set cookies
refresh/route.ts ← Proxy: reads httpOnly cookie, returns new access token
logout/route.ts ← Clears httpOnly refresh_token cookie