Skip to content

Cookbook

Invitation sign-in

This recipe lets a person create an account only by accepting an invitation to a team. The database checks the invitation code, rejects one that is unknown, expired or already used, and then creates the user and their team membership in the same step. Your application only passes the code and a password.

It is based on Setting up an invite system by Mordechai Hadad, which builds the same idea into a Rust application with projects, creators and permissions for each resource.

A user belongs to a team through a member_of relation. An invite holds the team, the email address it was sent to, a random code, an expiry time and the user who used it.

DEFINE TABLE user SCHEMAFULL PERMISSIONS FOR select WHERE id = $auth;
DEFINE FIELD email ON user TYPE string ASSERT string::is_email($value);
DEFINE FIELD password ON user TYPE string PERMISSIONS FOR select NONE;
DEFINE INDEX user_email ON user FIELDS email UNIQUE;

DEFINE TABLE team SCHEMAFULL
	PERMISSIONS FOR select WHERE id IN $auth->member_of->team;
DEFINE FIELD name ON team TYPE string;

DEFINE TABLE member_of TYPE RELATION IN user OUT team SCHEMAFULL
	PERMISSIONS FOR select WHERE in = $auth;
DEFINE FIELD role ON member_of TYPE 'owner' | 'member';
DEFINE INDEX member_of_pair ON member_of FIELDS in, out UNIQUE;

DEFINE TABLE invite SCHEMAFULL PERMISSIONS NONE;
DEFINE FIELD team ON invite TYPE record<team>;
DEFINE FIELD email ON invite TYPE string ASSERT string::is_email($value);
DEFINE FIELD code ON invite TYPE string DEFAULT rand::string(24) READONLY;
DEFINE FIELD expires ON invite TYPE datetime DEFAULT time::now() + 7d;
DEFINE FIELD used_by ON invite TYPE option<record<user>>;
DEFINE INDEX invite_code ON invite FIELDS code UNIQUE;

The invite table has PERMISSIONS NONE, so a signed-in user cannot list invitations or read their codes. Only the access method below and system users can read them.

The SIGNUP block runs when someone accepts an invitation. Each THROW returns its message to the caller, so your application can show why the code was refused.

DEFINE ACCESS invite ON DATABASE TYPE RECORD
	SIGNUP {
		LET $invite = SELECT * FROM ONLY invite WHERE code = $code LIMIT 1;
		IF $invite = NONE { THROW 'This invitation does not exist' };
		IF $invite.used_by != NONE { THROW 'This invitation has already been used' };
		IF $invite.expires < time::now() { THROW 'This invitation has expired' };
		LET $user = CREATE ONLY user CONTENT {
			email: $invite.email,
			password: crypto::argon2::generate($password),
		};
		RELATE $user->member_of->($invite.team) CONTENT { role: 'member' };
		UPDATE $invite.id SET used_by = $user.id;
		RETURN $user;
	}
	SIGNIN {
		RETURN SELECT * FROM ONLY user
			WHERE email = $email AND crypto::argon2::compare(password, $password)
			LIMIT 1;
	}
	DURATION FOR SESSION 12h;

The variable is called $code rather than $token, because $token is a protected parameter that holds the claims of the current session.

The account takes its email address from the invitation, not from the person who signs up. An invitation sent to one address therefore cannot create an account for another.

A system user, or your backend, creates the invitation and sends the code to the invited person.

CREATE team:design CONTENT { name: 'Design' };
CREATE invite:ana CONTENT { team: team:design, email: 'ana@example.com' };
SELECT code, expires FROM invite:ana;
Sample output
[
	{
		code: 'mfCdX3Uyx4zVDcVEJC3X38uA',
		expires: d'2026-11-19T02:10:14.630889Z'
	}
]

The invited person signs up with the code and a password of their choice. With the JavaScript SDK, the variables of the SIGNUP block go in variables:

await db.signup({
	namespace: 'app',
	database: 'app',
	access: 'invite',
	variables: {
		code: 'mfCdX3Uyx4zVDcVEJC3X38uA',
		password: 'hunter123',
	},
});

The session is now signed in as the new user. A second sign-up with the same code fails with This invitation has already been used, and the other two checks return their own messages:

CodeResult
Valid and unusedA new user, a membership and a session
Used beforeThis invitation has already been used
Past its expires timeThis invitation has expired
Not in the invite tableThis invitation does not exist

After the first visit, the user signs in with the email address of the invitation and their password, through the SIGNIN block of the same access method:

await db.signin({
	namespace: 'app',
	database: 'app',
	access: 'invite',
	variables: {
		email: 'ana@example.com',
		password: 'hunter123',
	},
});

Signed in, the user sees their own record and the teams they belong to, and nothing from the invite table:

SELECT ->member_of.role AS roles, ->member_of->team.name AS teams FROM ONLY $auth;
Output
{
	roles: [
		'member'
	],
	teams: [
		'Design'
	]
}
  • Invite existing users. Add a second record access method, or a function called by a signed-in user, that takes a code and only creates the member_of relation.

  • Send the email from the database. An event on invite can call an email service with http::post when the network capability allows it.

  • Resend an invitation. Update expires on the existing record. The code stays the same, because the field is READONLY.

The original article shows the full flow in Rust, including invitations to individual projects.

Was this page helpful?