All projects

Event RSVP app

2019

I created a single-page application to share information about an upcoming event with authenticated guests and allow them to submit and edit RSVPs.

Access control

I used the Firebase SDK to implement OAuth authentication with Google and Facebook, and offered email/password authentication for those who do not want to use a third-party provider. Once signed in, each user's email address is checked against a database of known guests. If their email address is not on the list, they are prompted to submit an access request with a form that saves responses in a Cloud Firestore collection.

Content architecture

Since this is a serverless SPA hosted on a CDN, the pages and components are all publicly available and readable by a skilled visitor. The event information needed to be kept private, so it is structured in a Cloud Firestore collection protected by security rules that check for a custom claim on each user account, so that only approved users have access to this data.

Implementation highlights and challenges

Protecting against account enumeration

I implemented email/password authentication with a cloud function that protects against account enumeration and protects user privacy. The goal is to prevent a third party from being able to test whether there is an account with a particular email address by looking at the way the application responds to sign-in attempts, password reset requests, and account creation requests. The application needs to respond identically regardless of whether an account exists, including components of API responses that are not directly displayed to the user.

Since the Firebase web SDK returns information on whether a particular account exists, I used cloud functions to perform actions on the client's behalf, and respond identically until a user is successfully authenticated.

Account creation

The createAccount cloud function uses the Firebase Admin SDK to create the account and generate an email action link for email verification, which is sent to the provided email address.

If an auth/email-already-exists error is caught, the email indicates that the account already exists.

The response sent to the client simply indicates that an email has been sent to the provided address.

Signing in

The signInWithEmailAndPassword cloud function uses the Firebase web SDK (running within the function) to sign in, and if successful, uses the Firebase Admin SDK to create a custom auth token, which is passed back to the client.

If an auth/user-not-found or auth/wrong-password error is caught, the function returns a generic permission-denied error to the client.

Edge cases
  • If the createAccount cloud function is invoked and the account already exists, but the email address has not been verified, the function should update the user's password, and re-send the verification email.

    This mirrors the normal account creation flow for the user if this is the first time they have tried to create an account: that is, if a bad actor tried to create an account with their email address previously.

  • If the signInWithEmailAndPassword cloud function is invoked and the email address has not been verified, the function should return the same permission-denied error to the client, even if the password is correct.

    Otherwise, a bad actor could create an account with someone else's email address, then use their chosen password to sign in. If they don't get the permission-denied error, that would indicate that there was not previously an account with that email address.

Limitations
  • A third party could use the Firebase web SDK for authentication outside of this app, bypassing the above protections. But these protections do prevent users from simply observing network requests within this app to enumerate accounts.

  • Since the signInWithEmailAndPassword cloud function handles sign in for all email/password users, the app is vulnerable to a denial-of-service attack: if enough invalid requests are sent to the function, the Firebase platform's rate limiter will prevent the cloud function from authenticating any valid request as well.

    This could be mitigated by implementing rate limiting by IP address within the cloud function, preventing a large number of invalid requests from reaching the auth SDK.

Simplifying account recovery

I implemented a single "help signing in" workflow that identifies what information a user might need to sign in and emails it to them.

  • If the account is email/password based, a password reset link is included in the help email
  • If the account is OAuth based, the help email tells the user which service was previously used to sign in
  • If no account exists with that email address, the help email lets the user know

This reduces friction for the user by eliminating the need for them to determine what their problem is—as long as they know their email address, the appropriate guidance is provided automatically.

Managing user permissions after authentication

The application handles multiple categories of users:

  • Unapproved users (not in the database of known guests)
  • Approved guests (can access event information)
  • Admins (can view RSVP submissions)

I used approved and admin keys in an allowedEmails Firestore collection to define which elevated permissions each user has. They can't be stored by user ID, because that is not defined until the first time a user signs in.

I created a setCustomClaims cloud function that is triggered when a user account is created. The function looks for the keys in the allowedEmails collection, and uses the Firebase Admin SDK to set associated custom claims on the user.

As a fallback in case the trigger fails, the front-end interface observes the collection to determine if a custom claim should be set. If the claim is expected but access to the Firestore data is being denied, the app renders a button that manually calls the setCustomClaims cloud function, ensuring the user can gain access.

Since the client can read custom claims in the user object of an authenticated session, setting custom claims allows us to customize the front-end interface for each user based on their permission level, without the need for additional calls to the Firestore API to determine what their permission level is. At the same time, custom claims can be used to secure Firestore data from illegitimate reads and writes outside normal app functionality, without more complicated security rules that would look users up in the allowedEmails collection directly.

Refining database security rules

I structured security rules to minimize user access to only the necessary data and to validate all writes with tests that are as specific as possible.

Guests are structured in groups of two, since most guests for this event were invited in pairs, and there were no groups of three. Single guests are stored with null in place of the second person. Access to guest data and RSVP response data is controlled with granular security rules to restrict access to only members of each group.

Guest data (names, email addresses, and other metadata) is stored in the guests collection with each document storing data for both guests in a group. Each document is readable by either person:

function emailVerified() {
  return request.auth.token.email_verified
}

match /guests/{document} {
  allow read: if isAdmin();
  allow read: if (
    resource.data.email0 == request.auth.token.email
      ||
    resource.data.email1 == request.auth.token.email
  ) && request.auth.token.email_verified;
}

Note that we need to make sure the user's email address has been verified. Otherwise, an attacker would be able to create an account with a victim's email address and use the password they chose to authenticate against the API and read this data.

Outside of the guests collection, we need a way to check if a particular user is allowed to access a guest document:

function isAuthorizedGroupId(groupId) {
  return get(/databases/$(database)/documents/guests/$(groupId)).data.email0 == request.auth.token.email ||
    get(/databases/$(database)/documents/guests/$(groupId)).data.email1 == request.auth.token.email
}

This allows us to provide access to each document in the rsvps collection to both people in the associated group. RSVPs are stored by group, not by user, so each person can make changes for themselves and the other person in the group. Ultimately, the RSVP response data is shown to the user consistently regardless of which person in the group signs in to the app.

match /rsvps/{document} {
  allow read: if isAuthorizedGroupId(resource.data.groupId)
  allow create: if 
    isAuthorizedGroupId(request.resource.data.groupId)
    // additional write validation ...
    ;
}