OAuth, social login and magic links
Get the token before the redirect, verify it in the callback.
How it works
- On the click, before redirecting to the provider, call
execute(). The visitor is still on your page, so the browser checks run there. - Carry the token through the redirect in a short-lived cookie, or in the OAuth
stateif it fits your provider's URL limit. - In the callback, send it to
/v1/verifywith the provider's user id and email inaccount. - Tokens expire after 5 minutes. A user who takes longer at the provider (2FA, a new device) comes back with
expired: take the missing-token path, not a rejection.
You only learn in the callback whether the user is new, and the token action must match the verify action. So mint one signup and one login token on the click and send the matching one. A new GitHub user is a signup and counts toward your quota. A returning user is a login and is free. The unused token just expires.
Next.js: GitHub button
// app/login/github-button.tsx
'use client';
declare global {
interface Window {
Nolgard?: { ready: Promise<void>; execute(o: { action: string }): Promise<string> };
}
}
// One token per possible outcome: you only learn new or returning in the callback.
async function saveTokens() {
for (const action of ['signup', 'login']) {
let token = '';
try {
if (window.Nolgard) {
await window.Nolgard.ready;
token = await window.Nolgard.execute({ action });
}
} catch {} // script blocked: no token, the callback takes the missing-token path
document.cookie = `nf_${action}=${encodeURIComponent(token)}; Max-Age=300; Path=/; SameSite=Lax; Secure`;
}
}
export function GitHubButton() {
async function onClick() {
await saveTokens(); // before the redirect, while the visitor is still on your page
window.location.href = '/api/auth/github'; // your existing OAuth start route
}
return <button onClick={onClick}>Continue with GitHub</button>;
}Load https://nolgard.com/v1/client.js on the page as in the Next.js guide. The button is always enabled: without the script the cookies are empty and the callback still runs.
Next.js: callback route
// app/api/auth/github/callback/route.ts
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
import { createNolgard } from '@nolgard/node';
const nolgard = createNolgard({ secret: process.env.NOLGARD_SECRET! }); // failOpenShare defaults to 0.2
export async function GET(request: Request) {
// Your existing code: check state, exchange the code, read the GitHub user and verified email.
const { githubId, email } = await exchangeGitHubCode(request);
const existing = await findUserByGitHubId(githubId);
const action = existing ? 'login' : 'signup';
const jar = await cookies();
const token = decodeURIComponent(jar.get(`nf_${action}`)?.value ?? '');
jar.delete('nf_signup');
jar.delete('nf_login');
const result = await nolgard.verify({
token, // empty: missing-token
action,
remoteIp: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(),
account: { id: `github:${githubId}`, email },
});
if (result.error === 'invalid-secret' || result.error === 'invalid-input') {
return NextResponse.redirect(new URL('/login?error=try-again', request.url)); // fail closed, alert yourself
}
if (!existing) {
// missing-token or a stale token (expired after 5 minutes, replayed): not a bot verdict.
// GitHub already verified the email, so give the reduced grant.
const share = result.success ? result.grant.share : 0.2;
if (result.success && (result.decision === 'block' || result.decision === 'shadow')) {
return NextResponse.redirect(new URL('/login?error=signup-failed', request.url));
}
await createUser({ githubId, email, trialShare: share });
} else if (result.decision === 'challenge') {
return NextResponse.redirect(new URL('/login/confirm-email', request.url)); // your own step-up
}
await startSession(githubId);
return NextResponse.redirect(new URL('/dashboard', request.url));
}exchangeGitHubCode, findUserByGitHubId, createUser and startSession are your own functions. Use the same account id on every verify for that user, so logins link to the signup. With the Node SDK, a 400 such as missing-token comes back as a result with success: false, and timeouts or over-quota come back as decision: "limit" with share 0.2.
Magic links
Verify when the link is requested, not when it is clicked: that is when the browser is on your page. Send account.email so repeat accounts link by email. This also stops bots flooding your mail sender with link requests.
// app/api/magic-link/route.ts
import { NextResponse } from 'next/server';
import { createNolgard } from '@nolgard/node';
const nolgard = createNolgard({ secret: process.env.NOLGARD_SECRET! });
export async function POST(request: Request) {
const { email, token } = await request.json(); // token from execute() on submit, or none
const existing = await findUserByEmail(email);
const result = await nolgard.verify({
token: token ?? '',
action: existing ? 'login' : 'signup',
remoteIp: request.headers.get('x-forwarded-for')?.split(',')[0]?.trim(),
account: { email },
});
// Bots asking for links get the same answer and no email. Blocks are not billed.
if (result.success && (result.decision === 'block' || result.decision === 'shadow')) {
return NextResponse.json({ ok: true });
}
// missing-token: the link itself is the email confirmation; grant 0.2 when it is clicked.
const share = result.success ? result.grant.share : 0.2;
await sendMagicLink(email, { trialShare: existing ? undefined : share });
return NextResponse.json({ ok: true });
}On the form, call execute() on submit with the action that matches, the same way as the button above, and send no token if the script is blocked.
PHP
Same flow. Set the two cookies from the click handler above, then in the callback read the one that matches and pass it to nolgard_verify() from the PHP guide. An empty token returns missing-token.
// callback.php, after the code exchange
$action = $existing ? 'login' : 'signup';
$token = $_COOKIE["nf_$action"] ?? '';
setcookie('nf_signup', '', time() - 3600, '/');
setcookie('nf_login', '', time() - 3600, '/');
$result = nolgard_verify($token, $action, ['id' => "github:$githubId", 'email' => $email]);