Guide

Single-use invite links in SvelteKit

An invite link is a bearer credential with a delivery problem: anyone who holds the URL gets in. Most implementations handle that badly in predictable ways — storing reusable tokens, accepting them twice, or letting them live forever. This guide walks the whole lifecycle — generate, store, share, preview, claim, revoke — using the exact patterns shipped in our multi-tenant SvelteKit starter, where the invite path is covered by tests you can read.

Scope first: these excerpts come from a starter that runs on SQLite via better-sqlite3 in a single process. Where that assumption matters — concurrent clicks, multi-instance deployments — the limits are stated inline, along with what changes when they stop holding.

How naive invite links leak

Four failure modes show up again and again in hand-rolled invite systems:

  • The token is stored in plaintext. Any read against the invites table — a SQL injection elsewhere, a leaked backup, a chatty admin endpoint — now yields working credentials. The link outlives the database it came from.
  • Acceptance is read-then-write. Load the row, check it’s unused, mark it used. Two people clicking the same link at the same moment both see “unused” and both get in. Single-use that fails under concurrency isn’t single-use.
  • Expiry is missing, cosmetic, or a cleanup job. A cron job that deletes old invites means every link issued before the next run still works. Expiry has to be part of deciding whether a link is valid, not background housekeeping.
  • Nothing else is re-checked at click time. Roles, membership, seats, and billing state can all change between “link created” and “link clicked.” An implementation that trusts the moment of creation ships stale authorization.

Each fix below maps to code that exists and runs — file by file.

Generate once, store only the hash

The token itself is 32 random bytes from Node’s CSPRNG, encoded URL-safe:

export function newInviteToken(): string {
	return randomBytes(32).toString('base64url');
}

What reaches the database is only its SHA-256:

tokenHash: sha256(token),
expiresAtMs: Date.now() + ttlDays * 24 * 60 * 60 * 1000,
acceptedAtMs: null,
revokedAtMs: null

The schema column carries the invariant as a comment — tokenHash = sha256(rawToken), raw token shown once, never stored — and lookups hash the presented token before querying:

const invite = (
	await db.select().from(invites).where(eq(invites.tokenHash, sha256(token))).limit(1)
)[0];

This is the same discipline the starter applies to sessions (the sessions table’s primary key is sha256(token)), so a leaked database yields neither usable logins nor usable invites. It also makes guessing impractical: an attacker who can query the table still only sees hashes of 256-bit random values. Note what this costs you: nobody — including the person who sent the invite — can recover the link after the send moment. Design your UI around showing it once.

Creating an invite is a privileged action

Before anything is inserted, creation re-derives authority from server state: the actor needs the members.invite permission, the requested role must be a known one, and the role hierarchy must allow the grant — an actor can only grant roles strictly below their own rank, so admins mint member invites and owners mint admin invites, but nobody writes themselves upward through a form field:

requirePermission(input.actorRole, 'members.invite');
if (!isRole(input.role)) {
	throw new RbacError('bad_role', `Unknown role '${input.role}'.`);
}
if (!mayGrant(input.actorRole, input.role)) {
	throw new RbacError('hierarchy_violation', `A ${input.actorRole} cannot grant the '${input.role}' role.`);
}

Expiry defaults to seven days (DEFAULT_TTL_DAYS) and can be shortened per call. The audit trail records invite.createdwith the role and optional email in its metadata — with an explicit note that the raw token is never written to the audit log. That email field, by the way, is a human note, not enforcement: the link works for whoever holds it. Email-bound invites are a different feature; they need verified email delivery, and pretending an address column is a lock is how teams talk themselves into a hole.

Preview without mutating

When someone opens an invite link, the landing page should be able to show what they’ve been invited to — before any account or session is involved. A read-only peek function answers with one of five explicit states:

| { state: 'invalid' }
| { state: 'expired' }
| { state: 'revoked' }
| { state: 'accepted' }
| { state: 'valid'; orgName: string; role: Role; expiresAtMs: number }

Order matters here: unknown token first, then revoked, accepted, and expired checks — so every failure mode gets its own message while an unknown token reveals nothing about whether any invite exists. A test in the suite pins exactly that (“unknown tokens fail closed without leaking information”). Keeping the preview strictly read-only also means opening a link can never consume it — a crawler prefetching the URL changes nothing.

The atomic claim: single-use without a transaction

Acceptance runs a few cheap pre-checks on the row it already loaded (revoked? expired? already accepted? already a member?), verifies seats, then claims the invite with a conditional update:

const claimed = await db
	.update(invites)
	.set({ acceptedAtMs: Date.now(), acceptedByUserId: input.userId })
	.where(and(eq(invites.id, invite.id), isNull(invites.acceptedAtMs), isNull(invites.revokedAtMs)))
	.returning({ id: invites.id });
if (claimed.length === 0) throw new InviteError('already_accepted', 'This invite has already been used.');

In plain SQL, the WHERE clause — the actual concurrency gate — reads:

UPDATE invites
SET accepted_at_ms = ?, accepted_by_user_id = ?
WHERE id = ? AND accepted_at_ms IS NULL AND revoked_at_ms IS NULL;
-- zero rows updated = someone else got there first

Two people clicking simultaneously is a normal Tuesday, not a debugging session: exactly one UPDATE flips the row, and the loser receives an honest “already been used.” No explicit transaction is required for the claim itself — the predicate is the lock. Two honest footnotes. First, expiry is enforced at read time, not inside this predicate, so a link that crosses its deadline in the milliseconds between check and claim would still land; add expires_at_ms > ? to the WHERE clause if your requirements demand strictness. Second, on the starter’s single-process SQLite setup requests serialize anyway; the predicate is what keeps the property alive when you move to Postgres and real concurrency.

What acceptance re-checks at join time

Because state drifts between create and click, acceptance validates the world as it is now:

  • Membership: an existing membership row for the same org short-circuits with “already a member.” Behind that check sits a composite unique index on (org_id, user_id), so even a theoretical race can’t produce duplicates — the schema says no.
  • Seats: the billing seam is asked for the subscription state while memberships are counted; a full plan throws seat_limit with copy that tells the inviter what to do next (“Upgrade the plan or free a seat”), and no subscription at all throws subscription_required. The next guide in this series, on seat-based billing behind a billing adapter, covers that seam end to end.
  • Role: the granted role comes from the stored invite row, never from anything the client clicked. Since creation already applied the hierarchy check, an owner-role invite can only exist because an owner sent it.
  • Last-owner safety: joining never removes owners, so acceptance can’t strand an org — that invariant lives in the leave, remove-member, and ownership-transfer paths, each of which counts owners first and refuses to remove the final one.

On success, the route layer creates the session, redirects into the org, and lets one error mapper turn domain errors into HTTP responses — permission failures as 403, everything user-actionable as 400. Success and failure stay distinguishable at the protocol level, not just in a toast message.

Revocation rides the same columns

Revoking is a conditional update too, guarded by permissioninvites.revoke and scoped to the org:

const updated = await db
	.update(invites)
	.set({ revokedAtMs: Date.now() })
	.where(and(eq(invites.id, input.inviteId), eq(invites.orgId, input.orgId), isNull(invites.revokedAtMs), isNull(invites.acceptedAtMs)))
	.returning({ id: invites.id });
if (updated.length === 0) throw new InviteError('invalid_token', 'Invite not found, already used, or already revoked.');

The predicate makes revocation idempotent-safe: you can’t revoke twice, and you can’t revoke a link that was already claimed. Pending-invite listings filter on the same three signals — not accepted, not revoked, not expired — so the UI and the enforcement logic can never disagree about what “pending” means.

Production checklist

  • Tokens come from a cryptographic RNG with far more entropy than IDs need — 32 random bytes, base64url-encoded, generated application-side.
  • The database stores sha256(token) and nothing else. If your admin UI can reprint a working link weeks later, you stored the raw token.
  • Expiry is a millisecond-timestamp column compared against now() when the link is read — default 7 days, caller-overridable — not a cron job deleting rows.
  • Single-use means a conditional UPDATE whose WHERE clause rejects already-accepted and already-revoked rows. Zero rows updated = someone got there first.
  • Revocation is another conditional UPDATE on the same columns: an accepted link can’t be revoked after the fact, and a revoked link can’t be revoked twice.
  • Unknown tokens fail closed with one generic message; expired, revoked, and used links each return their own specific, human-readable one.
  • Creating an invite is permission-checked (members.invite) and hierarchy-checked (a granted role must rank strictly below the actor’s own).
  • Duplicate membership is impossible even under simultaneous clicks: the service checks first, and a unique (org_id, user_id) index guarantees it.
  • Seat limits bite at join time through a billing seam, so a plan change between link creation and click becomes an actionable error instead of silent over-limit.
  • Audit created / revoked / accepted events in the same service call as the mutation — with metadata that never contains the raw token.
  • Rate-limit failed auth attempts (our kit ships login/signup keys); if your threat model includes mass probing of public invite URLs, aim the same limiter seam at them.

All eleven lines are implemented, not aspirational, in the starter kit — the invite and seat paths alone carry eight test cases covering hierarchy at creation, single-use acceptance, expiry, revocation, fail-closed unknown tokens, and seat-limited joins. For the surrounding context — tenancy modeling and role enforcement — read the earlier guide on multi-tenancy in SvelteKit. The seat-limit gate is detailed in the guide on seat billing adapter pattern, and the role checks at invite creation follow the RBAC capability matrix.

Related reading

Get in touch

Questions about the product, team licenses, or anything else? We'll respond within 48 hours.

Max 2000 characters

Stored in our own database — no third party. Deleted on request.