Inviting Users to Your Platform
This guide walks you through building an email invitation flow for your application — the kind where an admin types in a teammate's email, that teammate gets an email with a "Join" link, and clicking the link drops them into your app with the right role pre-assigned.
If you've integrated with an identity platform before, the most important thing to know up front is this:
The invitation lives in your application, not in SingleSign. SingleSign is purely the identity provider. Your app stores the invite, sends the email, validates the token, and assigns the role. SingleSign's only job in this flow is to register or authenticate the invited user and hand a verified
human_guidback to you.
You will not need any non-standard scopes, machine-to-machine credentials, or admin APIs to build this. Everything below works with the same client_id and client_secret you got when you created your app in the developer portal.
What You Are Building
sequenceDiagram
participant Admin as Admin (logged in)
participant App as Your App
participant DB as Your Database
participant Mail as Email Service
participant Invitee as Invitee
participant SS as SingleSign
Admin->>App: POST /invitations { email, role }
App->>DB: Insert invitation row (token, email, role, expires_at)
App->>Mail: Send invite email with link to /invite/{token}
Invitee->>App: GET /invite/{token}
App->>DB: Validate token (not expired, not used, not revoked)
App->>Invitee: Redirect to SingleSign /oauth/authorize?action=register&...
Invitee->>SS: Register or sign in
SS->>App: Redirect to /auth/callback?code=...&state=...
App->>SS: POST /oauth/token (exchange code)
SS-->>App: id_token + access_token (contains human_guid, email)
App->>DB: Match invite by state, verify email, mark accepted, assign role
App->>Invitee: Logged in, redirected into the app
The flow has three logical phases:
- Create the invite — Persist it in your DB and email a unique link.
- Bridge the invitee through SingleSign — Send them through the standard registration / sign-in flow.
- Accept the invite on callback — Match the SingleSign-verified user to the pending invite and grant access.
Prerequisites
- A registered SingleSign app with Application Type: Server Web Application. See Register an App.
- Your
client_id,client_secret, andplatform_guidfrom the portal. - The basics of the SingleSign login flow working in your app. See the Server Web App Guide.
- An email-sending service (SendGrid, Postmark, AWS SES, Resend, etc.).
- Node.js 18+ for the code samples below. The same patterns apply in any language.
Step 1 — Decide Who Can Register on Your Platform
Open your app in the developer portal and click the Registration tab. You'll see a setting called Allow public registration.
| Mode | What it means | When to use it |
|---|---|---|
| Public registration ON (recommended for most apps) | Anyone with the link can complete ?action=register and create an account on your platform. Your invite token is what authorizes their access inside your app once they're back. | Most B2B SaaS, team collaboration tools, customer portals. |
| Public registration OFF | SingleSign will refuse to let anyone create an account on your platform from the standard register URL. | Highly regulated platforms where every account must be pre-authorized at the IdP layer. |
For this guide, set "Allow public registration" to ON. The invitation gating happens in your app — only people you've invited will have a valid invite token, and only valid invite tokens will get them past your /invite/{token} landing page.
Why not gate registration at the SingleSign layer?
Because your app is the source of truth for "who is invited and what role do they get." Putting that logic in SingleSign would mean every invite is two writes (one to SingleSign, one to your DB) that can drift out of sync. Storing the invite in your DB and using SingleSign purely for identity verification is simpler and matches how mature SaaS products typically integrate with an IdP.
Step 2 — Design Your Invitation Table
Add an invitations table (or equivalent) to your application database. The minimum columns:
CREATE TABLE invitations (
id UUID PRIMARY KEY,
token TEXT NOT NULL UNIQUE, -- url-safe random, 32+ bytes
email TEXT NOT NULL,
role TEXT NOT NULL, -- whatever role string your app uses
status TEXT NOT NULL DEFAULT 'pending', -- pending | accepted | revoked | expired
expires_at TIMESTAMPTZ NOT NULL,
invited_by UUID NOT NULL, -- the human_guid of the admin who sent it
accepted_by UUID, -- the human_guid SingleSign returns on accept
accepted_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_invitations_email_status ON invitations (LOWER(email), status);
A reasonable default lifetime is 7 days. Longer than that and the email starts to feel forgotten; shorter and you'll get complaints from people on vacation.
Step 3 — Create the Invitation
When an admin submits the "Invite a teammate" form, your backend should:
- Verify the admin is authenticated and authorized to invite for the given role.
- Generate a cryptographically random invite token.
- Insert the row.
- Send the email.
// invitations.ts
import crypto from "node:crypto";
import { db } from "./db";
import { sendInvitationEmail } from "./email";
const INVITE_LIFETIME_DAYS = 7;
export async function createInvitation(input: {
email: string;
role: string;
invitedBy: string; // admin's human_guid
}): Promise<{ id: string; token: string; expiresAt: Date }> {
const id = crypto.randomUUID();
const token = crypto.randomBytes(32).toString("base64url");
const expiresAt = new Date(
Date.now() + INVITE_LIFETIME_DAYS * 24 * 60 * 60 * 1000
);
await db.query(
`INSERT INTO invitations (id, token, email, role, expires_at, invited_by)
VALUES ($1, $2, LOWER($3), $4, $5, $6)`,
[id, token, input.email, input.role, expiresAt, input.invitedBy]
);
const inviteUrl = `${process.env.APP_BASE_URL}/invite/${token}`;
await sendInvitationEmail(input.email, inviteUrl);
return { id, token, expiresAt };
}
The inviteUrl points back to your own app, not to SingleSign. Your landing page handles validation and presentation; SingleSign only enters the picture at the next hop.
A simple invitation email
// email.ts
export async function sendInvitationEmail(toEmail: string, inviteUrl: string) {
await mailer.send({
to: toEmail,
subject: "You've been invited to join Acme",
html: `
<p>Hi,</p>
<p>You've been invited to join Acme. Click below to accept the invitation:</p>
<p><a href="${inviteUrl}">Accept invitation</a></p>
<p>This link expires in 7 days.</p>
`,
});
}
Step 4 — Build the Invite Landing Page
When the invitee clicks the link, your server should validate the token before bouncing them to SingleSign. This gives you a chance to render a clean error page if the link is bad, expired, or already used.
// routes/invite.ts
import express from "express";
import crypto from "node:crypto";
import { db } from "./db";
import { generateCodeVerifier, generateCodeChallenge } from "./pkce";
import { singleSignConfig } from "./config";
const router = express.Router();
router.get("/invite/:token", async (req, res) => {
const { token } = req.params;
const result = await db.query(
`SELECT id, email, status, expires_at
FROM invitations
WHERE token = $1`,
[token]
);
if (result.rowCount === 0) {
return res.status(404).render("invite-error", {
message: "This invitation link is not valid.",
});
}
const invite = result.rows[0];
if (invite.status === "accepted") {
return res.render("invite-error", {
message: "This invitation has already been accepted. Please sign in.",
});
}
if (invite.status === "revoked") {
return res.render("invite-error", {
message: "This invitation has been revoked.",
});
}
if (new Date(invite.expires_at) < new Date()) {
return res.render("invite-error", {
message: "This invitation has expired. Please ask for a new one.",
});
}
// Stash invite + PKCE + state in a short-lived signed cookie or session,
// then redirect the invitee to SingleSign's registration flow.
const codeVerifier = generateCodeVerifier();
const codeChallenge = generateCodeChallenge(codeVerifier);
const state = crypto.randomBytes(16).toString("base64url");
req.session.invite = {
id: invite.id,
email: invite.email,
state,
codeVerifier,
};
const params = new URLSearchParams({
platform_guid: singleSignConfig.platformGuid,
response_type: "code",
client_id: singleSignConfig.clientId,
redirect_uri: singleSignConfig.redirectUri,
scope: "openid profile email",
state,
code_challenge: codeChallenge,
code_challenge_method: "S256",
action: "register", // ← fallback page if SingleSign can't determine account existence (see below)
login_hint: invite.email, // pre-fills AND locks the email through login or sign-up
});
// Optional sign-up pre-fills — send only the values you actually hold.
// They populate the matching registration fields but stay editable.
if (invite.firstName) params.set("first_name", invite.firstName);
if (invite.lastName) params.set("last_name", invite.lastName);
if (invite.phoneNumber) params.set("phone_number", invite.phoneNumber);
res.redirect(`${singleSignConfig.authorizationEndpoint}?${params}`);
});
export default router;
A couple of details worth calling out:
login_hintdrives the routing. When it's present, SingleSign checks at click time whether that email already has an account. If it does, the invitee lands on the sign-in page; if not, they land on the sign-up flow. In both cases the email field is pre-filled and locked — the invitee cannot change it anywhere in the flow, so the account that comes back on your callback is for the address you invited. To onboard a different address, send a new invite.actionis a fallback, not a router. It only decides which page is shown when SingleSign can't determine account existence (for example, a transient lookup failure). Keep sendingaction=registerfor invites.first_name,last_name,phone_numberare optional pre-fills. They populate the matching sign-up fields so the invitee types less. Unlike the email they stay editable, and they're ignored for users who already have an account.- The CSRF
stateand PKCEcode_verifiergo in your server-side session, not in the URL. The invite ID also goes in the session so the callback can find it later.
Step 5 — Handle the Callback and Accept the Invite
After the invitee finishes registering or signing in, SingleSign redirects to your redirect_uri with code and state. Your callback handler exchanges the code for tokens and finalizes the invite.
// routes/auth-callback.ts
router.get("/auth/callback", async (req, res) => {
const { code, state, error, error_description } = req.query;
if (error) {
return res.status(400).send(`Authorization error: ${error_description}`);
}
const pendingInvite = req.session.invite;
if (!pendingInvite || state !== pendingInvite.state) {
return res.status(403).send("State mismatch — please restart your invitation.");
}
// 1. Exchange the auth code for tokens.
const tokenResp = await fetch(singleSignConfig.tokenEndpoint, {
method: "POST",
headers: { "Content-Type": "application/x-www-form-urlencoded" },
body: new URLSearchParams({
grant_type: "authorization_code",
code: String(code),
redirect_uri: singleSignConfig.redirectUri,
client_id: singleSignConfig.clientId,
client_secret: singleSignConfig.clientSecret,
code_verifier: pendingInvite.codeVerifier,
platform_guid: singleSignConfig.platformGuid,
}),
});
if (!tokenResp.ok) {
const err = await tokenResp.json();
return res.status(400).send(`Token exchange failed: ${err.error_description}`);
}
const tokens = await tokenResp.json();
// 2. Get verified identity claims from the UserInfo endpoint
// (you can also decode the id_token, but UserInfo is the simplest).
const userResp = await fetch(singleSignConfig.userinfoEndpoint, {
headers: { Authorization: `Bearer ${tokens.access_token}` },
});
const user = await userResp.json();
// user = { sub, human_guid, email, email_verified, name, ... }
// 3. Verify the SingleSign-authenticated email matches the invite.
if (user.email.toLowerCase() !== pendingInvite.email.toLowerCase()) {
return res.status(403).render("invite-error", {
message:
"The account you signed in with doesn't match the invited email address.",
});
}
// 4. Atomically mark the invite accepted and provision the user.
await db.query("BEGIN");
try {
const updated = await db.query(
`UPDATE invitations
SET status = 'accepted', accepted_by = $1, accepted_at = now(), updated_at = now()
WHERE id = $2 AND status = 'pending' AND expires_at > now()
RETURNING role`,
[user.human_guid, pendingInvite.id]
);
if (updated.rowCount === 0) {
await db.query("ROLLBACK");
return res.status(409).render("invite-error", {
message: "This invitation is no longer valid.",
});
}
const role = updated.rows[0].role;
// Upsert the user in your own users table, keyed on human_guid.
await db.query(
`INSERT INTO users (human_guid, email, name, created_at)
VALUES ($1, $2, $3, now())
ON CONFLICT (human_guid) DO UPDATE SET
email = EXCLUDED.email,
name = EXCLUDED.name`,
[user.human_guid, user.email, user.name]
);
// Assign the role on your platform — your own role table, your own logic.
await db.query(
`INSERT INTO user_roles (human_guid, role) VALUES ($1, $2)
ON CONFLICT (human_guid, role) DO NOTHING`,
[user.human_guid, role]
);
await db.query("COMMIT");
} catch (err) {
await db.query("ROLLBACK");
throw err;
}
// 5. Establish your own session and send the user into the app.
req.session.user = {
humanGuid: user.human_guid,
email: user.email,
name: user.name,
};
req.session.accessToken = tokens.access_token;
req.session.refreshToken = tokens.refresh_token;
delete req.session.invite;
res.redirect("/dashboard?welcome=1");
});
The only thing SingleSign is doing here is proving "this person controls this email address and has these claims." Everything else — what role they get, where they show up in your data model, what they can see — is your code's call.
Step 6 — Resend, Revoke, and Clean Up
Mature invite flows need three more endpoints:
// Resend: just regenerate the email; the existing token still works.
export async function resendInvitation(id: string) {
const result = await db.query(
`SELECT token, email FROM invitations
WHERE id = $1 AND status = 'pending' AND expires_at > now()`,
[id]
);
if (result.rowCount === 0) throw new Error("Invitation not resendable");
const { token, email } = result.rows[0];
const inviteUrl = `${process.env.APP_BASE_URL}/invite/${token}`;
await sendInvitationEmail(email, inviteUrl);
}
// Revoke: invalidate the token before it's accepted.
export async function revokeInvitation(id: string) {
await db.query(
`UPDATE invitations
SET status = 'revoked', updated_at = now()
WHERE id = $1 AND status = 'pending'`,
[id]
);
}
// Sweep: a daily cron / scheduled job that flips overdue invites to "expired".
// Optional — you can also compute the expired status on the fly.
export async function expireStaleInvitations() {
await db.query(
`UPDATE invitations
SET status = 'expired', updated_at = now()
WHERE status = 'pending' AND expires_at <= now()`
);
}
Don't issue a brand-new token on resend unless you also revoke the old one — otherwise you'll have two valid tokens for the same email, and that gets ugly to reason about quickly.
Edge Cases to Handle
The invitee already has a SingleSign account
This is the most common case after the first few weeks — and you don't have to do anything. Because you send login_hint, SingleSign checks at click time whether the email already has an account and routes accordingly: existing account → sign-in page, no account → sign-up flow. The email is pre-filled and locked either way. After they sign in, the rest of the flow is identical: callback → token exchange → email match → role assignment.
You do not need to branch on your own users table to pick action=login vs action=register — older versions of this guide suggested that, but SingleSign now makes the decision from its own records, which are the source of truth. Always send action=register with the login_hint; returning users still land directly on sign-in.
The invitee signs in with a different email than they were invited at
The callback compares user.email (from SingleSign) against pendingInvite.email (from your DB) and rejects the mismatch. Always do this check — without it, anyone with a stolen invite link could redeem it under their own email.
The same email gets invited twice
Two reasonable policies:
- Last-write-wins: when a new invitation is created for an existing pending email, mark the old one revoked and the new one pending. Simplest UX.
- Idempotent: when a new invitation is created for an existing pending email, return the existing one and just resend the email.
Pick one and stick with it. Document the choice in your admin UI.
The invitee accepts twice (e.g. opens the link in two tabs)
The UPDATE ... WHERE status = 'pending' clause in Step 5 makes acceptance idempotent at the DB layer — only the first transaction wins, the second sees rowCount === 0 and renders the "not valid" page. That's the right behavior; don't try to be clever about it.
Security Notes
- Use cryptographically random tokens.
crypto.randomBytes(32)is the floor; anything less than 128 bits of entropy is guessable. - Always verify the SingleSign-authenticated email matches the invite email on callback. The locked email field makes a match the expected outcome, but the lock is a UX guarantee — your callback comparison remains the authoritative check. Without it, anyone who gets the link can redeem it.
- Treat the invite token as sensitive. Don't log it, don't put it in analytics URLs, don't include it in error messages shown to other users.
- Validate
stateon every callback. The example above checks it against the value stored in the server-side session. - Short expiries. 7 days is a good default. Anything longer should require manual approval to extend.
- Rate-limit invite creation per admin and per platform. An unbounded invite endpoint is a spam vector.
- Log invite events (created, sent, accepted, revoked, expired) with the admin's
human_guidand the invitee's email — this is your audit trail.
Frequently Asked Questions
Do I need to call any SingleSign admin API to create invites?
No. This is the most common misconception. The invitation lives entirely in your application database. SingleSign is only invoked when the invitee clicks the link, exactly the same way it would be for a normal sign-up. You do not need any non-OIDC scopes (like platforms.write), you do not need a client_credentials token, and you do not need to call any platform-management endpoints. The client_id and client_secret you already have are sufficient.
What if I want to lock down registration so only invited people can register?
That's the restricted-registration mode (Allow public registration = OFF). It currently requires special configuration on the SingleSign side and isn't fully self-service from the developer portal yet. If you have a regulatory or contractual reason you need it, contact SingleSign support — for most products, the in-app gating described in this guide gives you the same end-user experience without the extra moving parts.
Can I send invites from a backend cron job, when no admin user is logged in?
Yes. Creating the row and sending the email don't require a SingleSign user session — they're entirely on your side. The invitee still goes through the standard SingleSign sign-in flow when they click the link.
Can I attach extra metadata (department, team, custom claims) to the invite?
Yes. Just add columns to the invitations table. The metadata is read on accept and applied to your own user/role records. SingleSign doesn't need to know about any of it.
What happens if the invitee already has a SingleSign account on a different platform?
human_guid is unique per person across all SingleSign-integrated platforms, so they'll come through with the same identifier. They'll be prompted to consent to your platform's requested scopes the first time they sign in.
Do I need PKCE if I'm using a confidential server client?
It's strongly recommended even for confidential clients — defense in depth against authorization-code interception. The example code uses PKCE on every request.
Next Steps
- Server Web App Guide — Deeper coverage of the confidential-client flow this guide builds on.
- Scopes & Consent — Choosing the right scopes for your invite flow (most apps want
openid profile email). - Tokens & Claims — What's actually inside the
id_tokenyou get back at the callback. - Endpoint Reference — Every OAuth endpoint with curl examples.
- Troubleshooting — If your callback isn't firing or the token exchange fails.
Last updated: 2026-08-04
Related docs
- SingleSign Developer DocumentationLearn how to integrate SingleSign identity and authentication into your applications using OAuth 2.0 and OpenID Connect.
- free identity providerSingleSign pricing in one line: sign-in is free, with no monthly-active-user meter. See exactly what is included, and what SingleSign Mail costs for businesses.
- Identity provider alternativesIdentity provider alternatives, by the provider you are leaving: what actually has to change in your code, and what does not.
- Auth0 comparisonSingleSign vs Auth0 compared on the difference that matters: who owns the account. A side-by-side table, then the three cases where each one is the right call.
- Okta comparisonSingleSign vs Okta: Okta is built for workforce identity inside a company, SingleSign for consumer sign-in across applications.
- Firebase Auth comparisonSingleSign vs Firebase Authentication on lock-in, portability and consent, plus the cases where staying on Firebase is right.
- SingleSign vs Google Sign-InSingleSign vs Google Sign-In: the same one-tap convenience, without an advertising business behind the identity.
- alternative to Auth0Auth0 alternatives compared, plus the part most listicles skip: what migrating off Auth0 actually involves, which code changes, and which does not.