Create your first gate.
Start with a real Server Gate: JamAuth handles magic-link login, then your server decides whether to send the page, file, route, or API response. Once that works, you can add lighter modes or richer access rules.
Your Client ID
This identifies your JamAuth project. The steps below fill it in for you. It is not a secret.
You need a JamAuth project first
Create one and come back to this page from your dashboard so it can fill in your details.
Create a gateStart with Server Gate
Server Gate is the recommended first test because it checks access before content is sent. Visibility Mode is available for low-risk show/hide use cases.
Install it
Recommended first test: deploy the Netlify starter. Existing project? Use the AI builder prompt or the by-hand snippets below.
Deploy the Netlify starter First test
Fastest way to see the real gate working. One click gives you a site with the login page, the callback, and a protected route already wired up.
Netlify gives your site a URL like https://your-site-name.netlify.app. You'll need it in Step 3.
Hand it to your AI builder
Best for an existing app or site on any stack that can run a server route. Copy the instruction below and paste it into Cursor, Claude Code, Codex, Copilot, or v0. It points the assistant at the integration contract and tells it to wire in the three pieces.
The integration contract lives at /setup/jamauth-integration.md. Give it to your AI coding tool to wire JamAuth into your stack.
Install by hand — Netlify/serverless example
Exact Netlify-style serverless snippets if you would rather wire it yourself: add the callback function and a login page, set the two settings in Step 3, then verify. For other stacks, use the AI integration file as the universal contract.
Callback function. Path: netlify/functions/jamauth-callback.js (or your framework's server route). JamAuth redirects here after login; it sets a first-party session cookie on your domain.
Callback URL: https://your-site-name.netlify.app/.netlify/functions/jamauth-callback
/**
* JamAuth callback.
*
* Receives the magic-link callback from JamAuth,
* exchanges the login grant, and sets a first-party
* session cookie on your site.
*/
exports.handler = async (event) => {
// Only allow GET
if (event.httpMethod !== 'GET') {
return {
statusCode: 405,
headers: {
'Allow': 'GET',
'Content-Type': 'text/plain'
},
body: 'Method not allowed'
};
}
const JAMAUTH_HOST = process.env.JAMAUTH_HOST || 'https://api.jamauth.com';
const JAMAUTH_CLIENT_ID = process.env.JAMAUTH_CLIENT_ID;
// Cookie name is chosen after we know whether the request is HTTPS (see below).
if (!JAMAUTH_CLIENT_ID) {
console.error('[JamAuth Callback] Missing JAMAUTH_CLIENT_ID environment variable');
return errorResponse('Server configuration error');
}
// Read query parameters
const params = event.queryStringParameters || {};
const code = params.code;
const state = params.state;
if (!code) {
return errorResponse('Missing authorization code');
}
// Read PKCE verifier from cookie
const cookieHeader = event.headers.cookie || event.headers.Cookie || '';
const pkceCookie = parseCookie(cookieHeader, '__jamauth_pkce');
if (!pkceCookie) {
return errorResponse('Missing PKCE verifier cookie. Please restart the login flow.');
}
// Decode PKCE cookie (base64url JSON)
let pkceData;
try {
const decoded = base64UrlDecode(pkceCookie);
pkceData = JSON.parse(decoded);
} catch (err) {
console.error('[JamAuth Callback] Failed to decode PKCE cookie');
return errorResponse('Invalid PKCE cookie');
}
const codeVerifier = pkceData.v;
const expectedState = pkceData.s;
if (!codeVerifier) {
return errorResponse('Invalid PKCE data');
}
// Optional: Verify state matches (CSRF protection)
if (state && expectedState && state !== expectedState) {
console.error('[JamAuth Callback] State mismatch');
return errorResponse('State validation failed');
}
// Call JamAuth resolve-grant with PKCE verifier
try {
const response = await fetch(`${JAMAUTH_HOST}/.netlify/functions/resolve-grant`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
code: code,
client_id: JAMAUTH_CLIENT_ID,
code_verifier: codeVerifier
})
});
if (!response.ok) {
const errorData = await response.json().catch(() => ({}));
console.error('[JamAuth Callback] Grant resolution failed:', response.status, errorData.error);
return errorResponse('Authentication failed. Please try again.');
}
const data = await response.json();
const sessionToken = data.token || data.session_token;
if (!sessionToken) {
console.error('[JamAuth Callback] No session token in response');
return errorResponse('Invalid response from authentication service');
}
// Determine if we're on HTTPS (always use Secure in production)
const isSecure = !event.headers.host?.includes('localhost') &&
event.headers['x-forwarded-proto'] === 'https';
// __Host- cookies require Secure, which http://localhost cannot send, so the
// browser silently drops them locally. Use a local-only fallback name when not
// on HTTPS; keep __Host-jam_session in production.
const COOKIE_NAME = isSecure
? (process.env.JAMAUTH_COOKIE_NAME || '__Host-jam_session')
: 'jam_session_dev';
// Set session cookie + clear PKCE cookie
const cookies = [
// Session cookie (first-party, HttpOnly, long-lived)
`${COOKIE_NAME}=${sessionToken}; Path=/; HttpOnly; SameSite=Lax; Max-Age=2592000${isSecure ? '; Secure' : ''}`,
// Clear PKCE cookie
`__jamauth_pkce=; Path=/; SameSite=Lax; Max-Age=0${isSecure ? '; Secure' : ''}`
];
// Change DEFAULT_REDIRECT if your protected page or gated route lives somewhere else.
const DEFAULT_REDIRECT = '/.netlify/functions/protected-page';
const requestedRedirect = params.redirect || params.next || '';
const redirectTo = requestedRedirect.startsWith('/') && !requestedRedirect.startsWith('//')
? requestedRedirect
: DEFAULT_REDIRECT;
return {
statusCode: 302,
headers: {
'Location': redirectTo,
'Cache-Control': 'no-store'
},
multiValueHeaders: {
'set-cookie': cookies
},
body: ''
};
} catch (error) {
console.error('[JamAuth Callback] Unexpected error:', error.message);
return errorResponse('Authentication service unavailable');
}
};
function parseCookie(cookieHeader, name) {
const cookies = cookieHeader.split(';');
for (const cookie of cookies) {
const parts = cookie.trim().split('=');
const key = parts.shift();
const value = parts.join('=');
if (decodeURIComponent(key) === name) {
return decodeURIComponent(value || '');
}
}
return null;
}
function base64UrlDecode(str) {
let base64 = str.replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) {
base64 += '=';
}
return Buffer.from(base64, 'base64').toString('utf-8');
}
function errorResponse(message) {
return {
statusCode: 401,
headers: {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'no-store'
},
body: `<!DOCTYPE html><html><body style="font-family:system-ui;max-width:500px;margin:100px auto;text-align:center"><h1>Authentication Failed</h1><p>${message}</p><a href="/">Back to Home</a></body></html>`
};
}
Login page. Path: public/login.html. Loads the JamAuth widget and sends users back through your callback.
Server gate. Path: netlify/functions/protected-page.js (or your framework's protected route). This is the piece that actually protects content: it verifies the JamAuth session on the server before returning anything. Without it, login alone protects nothing.
/**
* JamAuth Server Gate (Netlify function example).
*
* Verifies the JamAuth session on the server BEFORE returning
* protected content. Fails closed: anything other than a valid
* 200 session is denied. Login alone does not protect this route -
* this check does.
*
* Path: netlify/functions/protected-page.js
*/
const JAMAUTH_HOST = process.env.JAMAUTH_HOST || 'https://api.jamauth.com';
function getCookie(cookieHeader, name) {
const cookies = String(cookieHeader || '').split(';');
for (const cookie of cookies) {
const parts = cookie.trim().split('=');
const key = parts.shift();
const value = parts.join('=');
if (decodeURIComponent(key || '') === name) {
return decodeURIComponent(value || '');
}
}
return '';
}
// Match the callback: production uses __Host-jam_session, local dev uses jam_session_dev.
function buildJamAuthCookieHeader(incoming) {
const prod = getCookie(incoming, '__Host-jam_session');
const dev = getCookie(incoming, 'jam_session_dev');
if (prod) return '__Host-jam_session=' + encodeURIComponent(prod);
if (dev) return 'jam_session_dev=' + encodeURIComponent(dev);
return '';
}
function redirect(location) {
return {
statusCode: 302,
headers: { 'Location': location, 'Cache-Control': 'no-store' },
body: ''
};
}
exports.handler = async (event) => {
const incoming = event.headers.cookie || event.headers.Cookie || '';
const jamAuthCookie = buildJamAuthCookieHeader(incoming);
// No session cookie: deny before doing anything else.
if (!jamAuthCookie) return redirect('/login.html');
let res;
try {
res = await fetch(JAMAUTH_HOST + '/.netlify/functions/refresh-session', {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Cookie': jamAuthCookie }
});
} catch (err) {
return redirect('/login.html'); // fail closed if JamAuth is unreachable
}
// Only 200 means authenticated. 402 = valid session but access/billing
// inactive: send to billing, not into a login loop. Everything else denies.
if (res.status !== 200) {
return redirect(res.status === 402 ? '/billing' : '/login.html');
}
const headers = {
'Content-Type': 'text/html; charset=utf-8',
'Cache-Control': 'private, no-store'
};
// If JamAuth rotated the session, forward the new cookie to the browser.
const rotated = res.headers.get('set-cookie');
if (rotated) headers['Set-Cookie'] = rotated;
// Optional app-specific rules (allowed emails, roles, ownership, plan tier)
// go here, after the session check and before any content is sent.
return {
statusCode: 200,
headers,
body: '<h1>Members only</h1>'
};
};
This covers the core check. For the full contract — refresh-session statuses, 402 → billing, rotated cookies, and app-specific rules — see the integration file.
One step: paste a script tag on any page where showing or hiding content is enough, then mark the member-only section.
Paste the Visibility Mode snippet
Put this near the end of any page. Anything inside data-jamauth-members is shown after JamAuth confirms the visitor is signed in and allowed.
Visibility Mode controls what the page shows after it loads. It is a good fit for previews, member-only sections, lightweight course pages, and low-risk resources. If a file, download, video, or page itself must not be reachable directly, switch this step to Server Gate above.
Connect your project
Add these two settings so your server knows which JamAuth project it belongs to. In Netlify, open Site configuration → Environment variables (or your platform's equivalent) and add both.
JAMAUTH_HOST
https://api.jamauth.com
JAMAUTH_CLIENT_ID
Show as .env block
After adding them, redeploy so your site picks them up.
Tell JamAuth where your site lives. This is where it sends people back after they click their magic link.
For your first test, use approved emails. Stripe customer access and custom rules are in preview until live revocation and provider switching are fully enabled.
Verify it's in the right place
Open your login page, sign in, and confirm you land back on your site. Then run the test that matters:
The one test that counts: open your protected URL in a private window with no session. If the content loads before you log in, the gate is in the wrong place — the content is being served before JamAuth checks access. It should redirect you to log in instead.
Load the page signed out: the members-only content should be hidden. Sign in and reload: it should appear. Sign out: it should hide again.
This confirms the page is showing and hiding the right section. If the raw URL, file, or video itself needs to stay private, use Server Gate.
More
When you'll need a client secret
The starter and the snippets above work without one. You only need a JamAuth client secret if you write a server route that calls JamAuth to validate access directly. Store it as a server environment variable. Never put it in static HTML or browser JavaScript.
Server Gate vs Visibility Mode, in one line
Server Gate checks access before content is sent. Visibility Mode checks access after the page loads and controls what visitors see. Use the one that matches the stakes.
Other platforms
The same pieces — a login widget, a callback, and a server-side check — work on any host that can run a server route: Netlify, Vercel, Cloudflare, a Next.js API route, your own server. The AI integration file is the quickest way to adapt them to your stack.