DEV Community

ZeligHolloway9071
ZeligHolloway9071

Posted on

Identity Verification Gates Invite Acceptance Authentication: 2-Stage User Creation After Proof

Short answer: keep an invitation in a pending state, verify the claimant's identity, and create the user record only after that proof succeeds. For a B2B SaaS product, make the same state machine own deletion: a GDPR delete must revoke every session before the account disappears.

Here is the decision note I use when reviewing an invite flow. It is deliberately boring. Boring auth survives audits.

Design Strength Cost Best fit
Pending invite plus verified claim Clear ownership and a small attack surface Requires explicit state transitions Most invite-only SaaS products
Pre-created disabled user Easy to attach data early Leaves orphaned identities and confusing deletion paths Teams with a legacy user table
External identity link first Good for enterprises that already control identity Couples onboarding to an identity provider's lifecycle Workforce SSO programs

I choose the first row for customer-facing B2B SaaS. The invite is a capability with an expiry, not a user. That distinction keeps unverified email addresses out of authorization queries and makes a later erase operation measurable.

How should invite acceptance authentication verify identity before user creation?

Treat acceptance as a transaction with four states: issued, claimed, verified, and consumed. Store a cryptographically random, single-use token as a hash. The URL carries the raw token; the database never does. Bind the invite to the intended email address, but do not treat possession of an email string as proof of identity.

The first request only claims the invite. It can show a sign-in or verification challenge, but it must not insert a row in users. I have seen teams create that row to “reserve” a seat, then discover that an attacker who forwarded a link also gained an object to which application data was attached. That is an authorization bug wearing a provisioning costume.

After the challenge succeeds, compare the verified subject and normalized email to the invite's constraints. A mismatch is a normal denial path. Return the same public response for an unknown, expired, or already-consumed token so the endpoint does not become an account enumerator. OWASP's Authentication Cheat Sheet calls out generic responses and careful session handling for this reason.

The final transition should be atomic. In PostgreSQL terms, lock the invite row, check status = 'claimed', insert the user, attach memberships, and mark the token consumed in one transaction. If two browser tabs race, one commits and the other receives a harmless “already used” result. No duplicate membership. No half-created account.

Ship less.

This is the minimal shape in TypeScript. The repository methods are intentionally generic; the invariant matters more than a particular ORM. I don't care which ORM is underneath if it cannot express a row lock and a rollback in one transaction. That is a tooling constraint, not a style preference: the acceptance endpoint is the one place where a duplicate row becomes an authorization problem, and a flaky retry can leave a membership attached to the wrong tenant unless the database makes the decision once.

type Invite = {
  id: string;
  email: string;
  tokenHash: string;
  status: "issued" | "claimed" | "verified" | "consumed";
  expiresAt: Date;
};

async function acceptInvite(rawToken: string, verifiedEmail: string) {
  const tokenHash = await sha256(rawToken);

  return db.transaction(async (tx) => {
    const invite = await tx.invites.lockByTokenHash(tokenHash);
    if (!invite || invite.expiresAt <= new Date()) {
      return { ok: false as const, reason: "invalid_invite" };
    }
    if (invite.status !== "claimed" && invite.status !== "verified") {
      return { ok: false as const, reason: "not_ready" };
    }
    if (normalize(invite.email) !== normalize(verifiedEmail)) {
      return { ok: false as const, reason: "identity_mismatch" };
    }

    const user = await tx.users.insertIfAbsent({
      email: normalize(verifiedEmail),
      createdFromInviteId: invite.id,
    });
    await tx.memberships.attachInviteRole(invite.id, user.id);
    await tx.invites.markConsumed(invite.id);
    return { ok: true as const, userId: user.id };
  });
}
Enter fullscreen mode Exit fullscreen mode

The insertIfAbsent constraint is a second line of defense, not a substitute for the invite lock. Keep audit events separate from mutable profile data: invite.claimed, identity.verified, user.created, and sessions.revoked should each carry an immutable timestamp and correlation ID.

Where do deletion and session revocation fit?

GDPR deletion is the inverse pressure test for onboarding. A user can be validly created and still retain active browser, mobile, or API sessions after their profile is erased. Model deletion as a job with a visible status, then revoke every session family before removing personal data.

Use short-lived access tokens and a server-side session registry with a revokedAt value. For stateless tokens, keep a per-user session version (or a revocation set) that the verifier checks. Password reset, invite acceptance, and account deletion should all be able to invalidate sessions through the same service; three ad-hoc token blacklists will drift.

async function deleteAccount(userId: string, requestId: string) {
  return db.transaction(async (tx) => {
    await tx.sessions.revokeAll(userId, { requestId });
    await tx.apiKeys.revokeAll(userId, { requestId });
    await tx.users.markPendingDeletion(userId, { requestId });
  });
}

async function authorizeSession(sessionId: string) {
  const session = await db.sessions.find(sessionId);
  if (!session || session.revokedAt || session.expiresAt <= new Date()) {
    throw new Error("unauthorized");
  }
  return session.userId;
}
Enter fullscreen mode Exit fullscreen mode

Queue the irreversible erase only after revocation commits. Your worker can then remove profile fields, invitations, recovery artifacts, and backups according to the retention schedule. “Deleted” should be an observable fact: expose a deletion event, count remaining active sessions in a metric, and alert when that count is non-zero after the revoke transaction.

Which failure modes should the test suite make loud?

Start with property-style tests around transitions, not screenshots of a happy-path form. A token can be presented twice, after expiry, with a different verified email, or concurrently from two devices. None of those cases may create two users or leak whether an address is registered.

I keep a small table of adversarial cases in the repository. It catches regressions faster than a large end-to-end suite:

Case Expected result Invariant
Expired token Generic rejection No user row
Email mismatch Generic rejection Invite remains unconsumed
Two simultaneous accepts One success One user and one membership
Delete during a new login Login denied after revoke commit Zero active sessions
Replay after success Generic rejection Token stays consumed

Instrument each rejection with an internal reason, but keep the public response constant. Log token IDs as hashes, never raw invitation URLs. Redact email addresses in traces unless your privacy policy explicitly permits them.

The awkward bug is usually not cryptography. It is a timeout between “identity verified” and “user inserted.” Make the transaction boundary explicit and test a database rollback: if membership attachment fails, the user insert and invite consumption must roll back together. A useful failure drill is to terminate the worker after the insert but before the commit, replay the same token, and inspect every table touched by the transaction. The expected result is one committed user, one membership, and a token that is either still pending or fully consumed; an intermediate state is a design defect, not a customer support ticket.

When is a different design the better choice?

The pending-invite pattern is not suitable when an upstream workforce directory must provision users before they ever visit an invitation link. In that case, SCIM or an enterprise identity provider can own lifecycle events, while your service stores a local shadow record. Keep the same verification and revocation invariants at the boundary.

A pre-created disabled user can also be reasonable during a staged migration from a managed provider, especially when foreign keys already point at users. The catch is cleanup: define an expiry for disabled rows, prevent them from receiving application data, and prove that deletion removes both the shadow row and all sessions. Stick with the pending model when you control the schema; choose the directory-led model when the directory is the system of record.

I am not sure a single “GDPR delete” button can satisfy every retention regime. Legal holds, invoices, and abuse investigations may require selective retention. Put those exceptions in a documented policy layer, not in a hidden branch inside authentication code. Your mileage will vary by jurisdiction, but the security invariant does not: an erased account must not remain usable.

The useful benchmark is time-to-first-call for the acceptance endpoint and the number of tables touched by a revoke. If either grows every quarter, the state machine is telling you to simplify the integration.

References

https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
https://www.rfc-editor.org/rfc/rfc6265
https://www.rfc-editor.org/rfc/rfc7519
Enter fullscreen mode Exit fullscreen mode

Sources

https://cheatsheetseries.owasp.org/cheatsheets/Authentication_Cheat_Sheet.html
Enter fullscreen mode Exit fullscreen mode

Top comments (0)