Skip to main content
OAuth lets your users connect their own accounts to your app. Corsair handles state signing, token exchange, and encrypted storage — you just wire up two routes. Corsair handles security details automatically — CSRF protection, HMAC state verification, and token encryption. Tokens are refreshed automatically before every API call.
1

Configure your app

Add an OAuth plugin to your corsair instance. Make sure database and kek are set — both are required for token storage.
OAuth is supported by plugins like Gmail, Google Calendar, Notion, Spotify, Dropbox, and others.
corsair.ts
import { createCorsair } from 'corsair';
import { gmail } from '@corsair-dev/gmail';

export const corsair = createCorsair({
    plugins: [gmail()],
    kek: process.env.CORSAIR_KEK!,
    database: db,
});
Then store your OAuth app credentials once:
pnpm corsair setup --plugin=gmail client_id=YOUR_CLIENT_ID client_secret=YOUR_CLIENT_SECRET
2

The connect route

/api/connect generates the provider’s authorization URL and stores the HMAC-signed state in a cookie. The user is then redirected to the provider to approve access.
This route must be authenticated.If left open, anyone can call /api/connect?plugin=gmail&tenantId=victim-id and bind their OAuth grant to your victim’s account, silently overwriting that tenant’s stored tokens. Always read tenantId from your own session, never from a query parameter.
app/api/connect/route.ts
import { generateOAuthUrl } from 'corsair/oauth';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { corsair } from '@/server/corsair';
import { getSessionTenantId } from '@/server/auth'; // your auth logic

const REDIRECT_URI = `${process.env.APP_URL}/api/auth`;

export async function GET(request: NextRequest) {
    const tenantId = await getSessionTenantId(request);
    if (!tenantId) {
        return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
    }

    const plugin = new URL(request.url).searchParams.get('plugin');
    if (!plugin) {
        return NextResponse.json({ error: 'Missing plugin param' }, { status: 400 });
    }

    const { url, state } = await generateOAuthUrl(corsair, plugin, {
        tenantId,
        redirectUri: REDIRECT_URI,
    });

    const response = NextResponse.redirect(url);
    response.cookies.set('oauth_state', state, {
        httpOnly: true,   // not readable by JavaScript
        sameSite: 'lax',  // safe for provider redirects
        secure: process.env.NODE_ENV === 'production', // HTTPS only in prod
        maxAge: 60 * 10,  // expires in 10 minutes
    });
    return response;
}
3

The callback route

After the user approves, the provider redirects to /api/auth with ?code= and ?state=. This route verifies the state, exchanges the code for tokens, and stores them encrypted for the tenant.
This route must be authenticated. Verify the oauth_state cookie matches the query param before calling processOAuthCallback. Always clear the cookie on both success and failure — a stale state cookie is a security risk.
app/api/auth/route.ts
import { processOAuthCallback } from 'corsair/oauth';
import type { NextRequest } from 'next/server';
import { NextResponse } from 'next/server';
import { corsair } from '@/server/corsair';

const REDIRECT_URI = `${process.env.APP_URL}/api/auth`;

function escapeHtml(value: string): string {
    return value
        .replace(/&/g, '&')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#x27;');
}

export async function GET(request: NextRequest) {
    const { searchParams } = new URL(request.url);
    const code = searchParams.get('code');
    const state = searchParams.get('state');
    const error = searchParams.get('error');

    // Always clear the cookie — every exit path below must do this
    const clearCookie = {
        'Set-Cookie': 'oauth_state=; HttpOnly; Path=/; Max-Age=0',
        'Content-Type': 'text/html',
    };

    if (error) {
        return new NextResponse(
            `<html><body><h2>Authorization failed</h2><p>${escapeHtml(error)}</p></body></html>`,
            { status: 400, headers: clearCookie },
        );
    }

    if (!code || !state) {
        return new NextResponse('<p>Missing code or state.</p>', {
            status: 400,
            headers: clearCookie,
        });
    }

    const storedState = request.cookies.get('oauth_state')?.value;

    if (!storedState || storedState !== state) {
        return new NextResponse('<p>Invalid state. Possible CSRF attempt.</p>', {
            status: 400,
            headers: clearCookie,
        });
    }

    try {
        const result = await processOAuthCallback(corsair, {
            code,
            state,
            redirectUri: REDIRECT_URI,
        });

        const response = new NextResponse(
            `<html><body>
                <h2>Connected!</h2>
                <p><strong>${escapeHtml(result.plugin)}</strong> authorized for tenant
                <strong>${escapeHtml(result.tenantId)}</strong>.</p>
                <p><a href="/">Back to home</a></p>
            </body></html>`,
            { status: 200, headers: { 'Content-Type': 'text/html' } },
        );
        response.cookies.delete('oauth_state');
        return response;
    } catch (err) {
        const message = err instanceof Error ? err.message : String(err);
        const response = new NextResponse(
            `<html><body><h2>OAuth error</h2><p>${escapeHtml(message)}</p></body></html>`,
            { status: 500, headers: { 'Content-Type': 'text/html' } },
        );
        response.cookies.delete('oauth_state');
        return response;
    }
}

Environment variables

VariableDescription
CORSAIR_KEKKey-encryption key — generate with openssl rand -hex 32. Never rotate without re-encrypting stored DEKs.
APP_URLYour app’s public base URL (e.g. https://myapp.com). Used to build REDIRECT_URI.
NODE_ENVSet to production to enable the secure flag on cookies and other hardening.

Security checklist

Both /api/connect and /api/auth are authenticated routes
tenantId is read from your session — never from a query parameter
oauth_state cookie uses httpOnly, sameSite: 'lax', and secure in production
oauth_state cookie is cleared on every exit path — success, failure, and CSRF mismatch
All user-controlled values are HTML-escaped before rendering
APP_URL points to your HTTPS domain in production
State store is Redis or DB-backed for multi-instance deployments

What’s next

OAuth 2.0 Concepts

How Corsair encrypts and stores tokens, and handles automatic refresh.

Multi-Tenancy

Scoping every API call and database query per user with withTenant().

Gmail Plugin

Set up Gmail OAuth — a common starting point.

Google Calendar Plugin

Another popular OAuth plugin to connect.