Secure Data Connect with authorization and attestation

Firebase Data Connect provides robust client-side security with:

  • Mobile and web client authorization
  • Individual query- and mutation-level authorization controls
  • App attestation with Firebase App Check.

Data Connect extends this security with:

  • Server-side authorization
  • Firebase project and Cloud SQL user security with IAM.

Authorize client queries and mutations

Data Connect is fully integrated with Firebase Authentication, so you can leverage rich data about users who are accessing your data (authentication) in your design for what data those users can access (authorization).

Data Connect provides an @auth directive for queries and mutations that lets you set the level of authentication required to authorize the operation.

Understand the @auth directive

You can parameterize the @auth directive to follow one of several preset access levels that cover many common access scenarios. These levels range from PUBLIC (which allows queries and mutations from all clients without authentication of any kind) to NO_ACCESS (which disallows queries and mutations outside of privileged server environments using the Firebase Admin SDK). Each of these levels is correlated with authentication flows provided by Firebase Authentication.

Using these preset access levels as a starting point, you can define complex and robust authorization checks in the @auth directive using test filters and expressions evaluated on the server.

Best practices for common secure authorization scenarios

The preset access levels are the starting point for authorization.

The USER access level is the most widely useful basic level to start with.

Fully secure access will build on the USER level plus filters and expressions that check user attributes, resource attributes, roles and other checks. The USER_ANON and USER_EMAIL_VERIFIED levels are variations on the USER case.

Expression syntax lets you evaluate data using an auth object representing authentication data passed with operations, both standard data in auth tokens and custom data in tokens. For the list of fields available in the auth object, see the reference section.

There are of course use cases where PUBLIC is the correct access level to start with. Again, an access level is always a starting point, and additional filters and expressions are needed for robust security.

This guide now gives examples of how to build on USER and PUBLIC.

A motivating example

The following best practice examples refer to the following schema for a blogging platform with certain content locked behind a payment plan.

Such a platform would likely model Users andPosts.

type User @table(key: "uid") {
  uid: String!
  name: String
  birthday: Date
  createdAt: Timestamp! @default(expr: "request.time")
}

type Post @table {
  author: User!
  text: String!
  # "one of 'draft', 'public', or 'pro'"
  visibility: String! @default(value: "draft")
  # "the time at which the post should be considered published. defaults to
  # immediately"
  publishedAt: Timestamp! @default(expr: "request.time")
  createdAt: Timestamp! @default(expr: "request.time")
  updatedAt: Timestamp! @default(expr: "request.time")
}

User-owned resources

Firebase recommends that you write filters and expressions that test user ownership of a resource, in the following cases, ownership of Posts.

In the following examples, data from auth tokens is read and compared using expressions. The typical pattern is to use expressions like where: {authorUid: {eq_expr: "auth.uid"}} to compare a stored authorUid to the auth.uid (user ID) passed in the authentication token.

Create

This authorization practice starts by adding the auth.uid from the auth token to each new Post as an authorUid field to allow comparison in subsequence authorization tests.

# Create a new post as the current user
mutation CreatePost($text: String!, $visibility: String) @auth(level: USER) {
  post_insert(data: {
    # set the author's uid to the current user uid
    authorUid_expr: "auth.uid"
    text: $text
    visibility: $visibility
  })
}
Update

When a client attempts to update a Post, you can test the passed auth.uid against the stored authorUid.

# Update one of the current user's posts
mutation UpdatePost($id: UUID!, $text: String, $visibility: String) @auth(level:USER) {
  post_update(
    # only update posts whose author is the current user
    first: { where: {
      id: {eq: $id}
      authorUid: {eq_expr: "auth.uid"}
    }}
    data: {
      text: $text
      visibility: $visibility
      # insert the current server time for updatedAt
      updatedAt_expr: "request.time"
    }
  )
}
Delete

The same technique is used to authorize delete operations.

# Delete one of the current user's posts
mutation DeletePost($id: UUID!) @auth(level: USER) {
  post_delete(
    # only delete posts whose author is the current user
    first: { where: {
      id: {eq: $id}
      authorUid: {eq_expr: "auth.uid"}
    }}
  )
}
# Common display information for a post
fragment DisplayPost on Post {
  id, text, createdAt, updatedAt
  author { uid, name }
}
List
# List all posts belonging to the current user
query ListMyPosts @auth(level: USER) {
  posts(where: {
    userUid: {eq_expr: "auth.uid"}
  }) {
    # See the fragment above
    ...DisplayPost
    # also show visibility since it is user-controlled
    visibility
  }
}
Get
# Get a post only if it belongs to the current user
query GetMyPost($id: UUID!) @auth(level: USER) {
  post(key: {id: $id}, 
    first: {where: {
      id: {eq: $id}
      authorUid: {eq_expr: "auth.uid"}}
      }}, {
      # See the fragment above
      ...DisplayPost
      # also show visibility since it is user-controlled
      visibility
  }
}

Filter Data

Data Connect's authorization system lets you write sophisticated filters combined with preset access levels like PUBLIC as well as by using data from auth tokens.

The authorization system also allows you to use expressions only, without a base access level, as shown in some of the following examples.

Filtering by resource attributes

Here, authorization is not based on auth tokens since the base security level is set to PUBLIC. But, we can explicitly set records in our database as suitable for public access; assume we have Post records in our database with visibility set to "public".

# List all posts marked as 'public' visibility
query ListPublicPosts @auth(level: PUBLIC) {
  posts(where: {
    # Test that visibility is "public"
    visibility: {eq: "public"}
    # Only display articles that are already published
    publishedAt: {lt_expr: "request.time"}
  }) {
    # see the fragment above
    ...DisplayPost
  }
}
Filtering by user claims

Here, assume you've set up custom user claims that pass in auth tokens to identify users in a "pro" plan for your app, flagged with an auth.token.plan field in the auth token. Your expressions can test against this field.

# List all public or pro posts, only permitted if user has "pro" plan claim
query ProListPosts @auth(expr: "auth.token.plan == 'pro'") {
  posts(where: {
    # display both public posts and "pro" posts
    visibility: {in: ['public', 'pro']},
    # only display articles that are already published
    publishedAt: {lt_expr: "request.time"},
  }) {
    # see the fragment above
    ...DisplayPost
    # show visibility so pro users can see which posts are pro\
    visibility
  }
}
Filtering by order + limit

Or again, you may have set visibility in Post records to identify they are content available for "pro" users, but for a preview/teaser listing of data, further limit the number of records returned.

# Show 2 oldest Pro post as a preview
query ProTeaser @auth(level: USER) {
  posts(
    where: {
      # show only pro posts
      visibility: {eq: "pro"}
      # that have already been published more than 30 days ago
      publishedAt: {lt_time: {now: true, sub: {days: 30}}}
    },
    # order by publish time
    orderBy: [{publishedAt: DESC}],
    # only return two posts
    limit: 2
  ) {
    # See the fragment above
    ...DisplayPost
  }
}
Filtering by role

If your custom claim defines an admin role, you can test and authorize operations accordingly.

# List all posts unconditionally iff the current user has an admin claim
query AdminListPosts @auth(expr: "auth.token.admin == true") {
  posts { ...DisplayPost }
}

Antipatterns to avoid in authorization

The previous section covers patterns to follow when using the @auth directive.

You should also be aware of important antipatterns to avoid.

Avoid passing user attributes IDs and auth token parameters in query and mutation arguments

Firebase Authentication is a powerful tool for presenting authentication flows and securely capturing authentication data such as registered user IDs and numerous fields stored in auth tokens.

It's not a recommended practice to pass user IDs and auth token data in query and mutation arguments.

# Antipattern!
# This incorrectly allows any user to view any other user's posts
query AllMyPosts($userId: String!) @auth(level: USER) {
  posts(where: {authorUid: {eq: $userId}}) {
    id, text, createdAt
  }
}

Avoid using the USER access level without any filters

As discussed several times in the guide, the core access levels like USER, USER_ANON, USER_EMAIL_VERIFIED are baselines and starting points for authorization checks, to be enhanced with filters and expressions. Using these levels without a corresponding filter or expression that checks which user is performing the request is essentially equivalent to using the PUBLIC level.

# Antipattern!
# This incorrectly allows any user to view all documents
query ListDocuments @auth(level: USER) {
  documents {
    id
    title
    text
  }
}

Avoid using PUBLIC or USER access level for prototyping

To speed up development, it can be tempting to set all operations to the PUBLIC access level or to USER access level without further enhancements to authorize all operations and let you quickly test your code.

When you've done very simple prototyping this way, begin to switch from NO_ACCESS to production-ready authorization with PUBLIC and USER levels. However, do not deploy them as PUBLIC or USER without adding additional logic as shown in this guide.

# Antipattern!
# This incorrectly allows anyone to delete any post
mutation DeletePost($id: UUID!) @auth(level: PUBLIC) {
  post: post_delete(
    id: $id,
  )
}

Use Firebase App Check for app attestation

Authentication and authorization are critical components of Data Connect security. Authentication and authorization combined with app attestation makes for a very robust security solution.

With attestation through Firebase App Check, devices running your app will use an app or device attestation provider that attests that Data Connect operations originate from your authentic app and requests originate from an authentic, untampered device. This attestation is attached to every request your app makes to Data Connect.

To learn how to enable App Check for Data Connect and include its client SDK in your app, have a look at the App Check overview.

Authentication levels for the @auth(level) directive

The following table lists all standard access levels and their CEL equivalents. Authentication levels are listed from broad to narrow -- each level encompasses all users who match following levels.

Level Definition
PUBLIC The operation can be executed by anyone with or without authentication.

Considerations: Data can be read or modified by any user. Firebase recommends this level of authorization for publicly-browsable data like product or media listings. See the best practice examples and alternatives.

Equivalent to @auth(expr: "true")

@auth filters and expressions cannot be used in combination with this access level. Any such expressions will fail with a 400 bad request error.
USER_ANON Any identified user, including those who have logged in anonymously with Firebase Authentication, is authorized to perform the query or mutation.

Note: USER_ANON is a superset of USER.

Considerations: Note that you must carefully design your queries and mutations for this level of authorization. This level allows user to be logged in anonymously (automatic sign-in tied only to a user device) with Authentication, and does not on its own perform other checks on, for example, whether data belongs to the user. See the best practice examples and alternatives.

Since Authentication anonymous login flows issue a uid, the USER_ANON level is equivalent to
@auth(expr: "auth.uid != nil")
USER Any user who has logged in with Firebase Authentication is authorized to perform the query or mutation except anonymous sign-in users.

Considerations: Note that you must carefully design your queries and mutations for this level of authorization. This level only checks that the user is logged in with Authentication, and does not on its own perform other checks on, for example, whether data belongs to the user. See the best practice examples and alternatives.

Equivalent to @auth(expr: "auth.uid != nil && auth.token.firebase.sign_in_provider != 'anonymous'")"
USER_EMAIL_VERIFIED Any user who has logged in with Firebase Authentication with a verified email address is authorized to perform the query or mutation.

Considerations: Since email verification is performed via Authentication, it's based on a more robust Authentication method, thus this level provides additional security compared to USER or USER_ANON. This level only checks that the user is logged in with Authentication with a verified email, and does not on its own perform other checks on, for example, whether data belongs to the user. See the best practice examples and alternatives.

Equivalent to @auth(expr: "auth.uid != nil && auth.token.email_verified")"
NO_ACCESS This operation cannot be executed outside an Admin SDK context.

Equivalent to @auth(expr: "false")

CEL Reference for @auth(expr)

Test variables passed in queries and mutations

As hinted at previously, @auth(expr) syntax allows you access and test variables from queries and mutations.

You can either include an operation variable, for example $status, using the name status or using the full-qualified request.variables.status.

# The following are equivalent
mutation Update($id: UUID!, $status: Any) @auth(expr: "has(request.variables.status)")
mutation Update($id: UUID!, $status: Any) @auth(expr: "has(status)")`

Complex expression syntax

You can write more complex expressions by combining with the && and || operators.

mutation UpsertUser($username: String!) @auth(expr: "(auth != null) && (username == 'joe')")

The following section describes all available operators.

Operators and operator precedence

Use the table below as a reference for operators and their corresponding precedence.

Given arbitrary expressions a and b, a field f, and an index i.

Operator Description Associativity
a[i] a() a.f Index, call, field access left to right
!a -a Unary negation right to left
a/b a%b a*b Multiplicative operators left to right
a+b a-b Additive operators left to right
a>b a>=b a Relational operators left to right
a in b Existence in list or map left to right
a is type Type comparison, where type can be bool, int, float, number, string, list, map, timestamp, duration, path or latlng left to right
a==b a!=b Comparison operators left to right
a && b Conditional AND left to right
a || b Conditional OR left to right
a ? true_value : false_value Ternary expression left to right

Data available to expressions

CEL expressions can evaluate one of the following:

  • request.operationName
  • request.variables
  • request.auth

The request.operationName object

The request.operarationName object stores the type of operation, either query or mutation.

The request.variables object

The request.variables object allows your expressions to access all variables passed in your query or mutation.

You can use a variable name in an expression as an alias for the fully-qualified request.variables name:

# The following are equivalent
mutation StringType($v: String!) @auth(expr: "request.variables.v == 'hello'")
mutation StringType($v: String!) @auth(expr: "v == 'hello'")

The request.auth object

Authentication identifies users requesting access to your data and provides that information as a variable you can leverage in your expressions.

The auth variable contains the following information:

  • uid: A unique user ID, assigned to the requesting user.
  • token: A map of values collected by Authentication.

Data always present in auth tokens

The auth variable contains the following (required) values:

Field Description
email The email address associated with the account, if present.
email_verified true if the user has verified they have access to the email address. Some providers automatically verify email addresses they own.
phone_number The phone number associated with the account, if present.
name The user's display name, if set.
sub The user's Firebase UID. This is unique within a project.
firebase.identities Dictionary of all the identities that are associated with this user's account. The keys of the dictionary can be any of the following: email, phone, google.com, facebook.com, github.com, twitter.com. The values of the dictionary are arrays of unique identifiers for each identity provider associated with the account. For example, auth.token.firebase.identities["google.com"][0] contains the first Google user ID associated with the account.
firebase.sign_in_provider The sign-in provider used to obtain this token. Can be one of the following strings: custom, password, phone, anonymous, google.com, facebook.com, github.com, twitter.com.
firebase.tenant The tenantId associated with the account, if present. e.g. tenant2-m6tyz

Additional fields in JWT ID tokens

You can also access the following auth.token fields:

Custom Token Claims
alg Algorithm "RS256"
iss Issuer Your project's service account email address
sub Subject Your project's service account email address
aud Audience "https://identitytoolkit.googleapis.com/google.identity.identitytoolkit.v1.IdentityToolkit"
iat Issued-at time The current time, in seconds since the UNIX epoch
exp Expiration time The time, in seconds since the UNIX epoch, at which the token expires. It can be a maximum of 3600 seconds later than the iat.
Note: this only controls the time when the custom token itself expires. But once you sign a user in using signInWithCustomToken(), they will remain signed in into the device until their session is invalidated or the user signs out.
uid The unique identifier of the signed-in user must be a string, between 1-128 characters long, inclusive. Shorter uids offer better performance.
claims (optional) Optional custom claims to include in the expressionauth request.auth variables

What's next?