Skip to content

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_guid back 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:

  1. Create the invite — Persist it in your DB and email a unique link.
  2. Bridge the invitee through SingleSign — Send them through the standard registration / sign-in flow.
  3. 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, and platform_guid from 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.

ModeWhat it meansWhen 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 OFFSingleSign 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:

  1. Verify the admin is authenticated and authorized to invite for the given role.
  2. Generate a cryptographically random invite token.
  3. Insert the row.
  4. 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_hint drives 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.
  • action is 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 sending action=register for invites.
  • first_name, last_name, phone_number are 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 state and PKCE code_verifier go 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 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

  1. Use cryptographically random tokens. crypto.randomBytes(32) is the floor; anything less than 128 bits of entropy is guessable.
  2. 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.
  3. 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.
  4. Validate state on every callback. The example above checks it against the value stored in the server-side session.
  5. Short expiries. 7 days is a good default. Anything longer should require manual approval to extend.
  6. Rate-limit invite creation per admin and per platform. An unbounded invite endpoint is a spam vector.
  7. Log invite events (created, sent, accepted, revoked, expired) with the admin's human_guid and 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_token you 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