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 use 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. This guide
introduces the @auth
directive, with examples.
In addition, Data Connect supports execution of queries embedded in
mutations, so you can retrieve additional authorization criteria you've stored
in your database, and use those criteria in @check
directives to decide if
enclosing mutations are authorized. For this authorization case, the @redact
directive allows you control whether query results are returned to clients in
the wire protocol and the embedded query omitted in generated SDKs. Find the
introduction to these directives, with examples.
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.
Level | Definition |
---|---|
PUBLIC |
The operation can be executed by anyone with or without authentication. |
PUBLIC |
The operation can be executed by anyone with or without authentication. |
USER_ANON |
Any identified user, including those who have logged in anonymously with Firebase Authentication, is authorized to perform the query or mutation. |
USER |
Any user who has logged in with Firebase Authentication is authorized to perform the query or mutation except anonymous sign-in users. |
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. |
NO_ACCESS |
This operation cannot be executed outside an Admin SDK context. |
Using these preset access levels as a starting point, you can define complex and
robust authorization checks in the @auth
directive using where
filters and
Common Expression Language (CEL) expressions evaluated on the server.
Use the @auth
directive to implement common 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 lets you use expressions only, without a base access level, as shown in some of the following examples.
Filter 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
}
}
Filter 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
}
}
Filter 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 or 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
}
}
Filter 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 }
}
Understand the @check
and @redact
directives
The @check
directive verifies that specified fields are present in query
results. A Common Expression Language (CEL) expression is used to test field
values. The default behavior of the directive is to check for and reject
null
-valued nodes.
The @redact
directive redacts a part of the response from the client. Redacted
fields are still evaluated for side effects (including data changes and
@check
) and the results are still available to later steps in CEL expressions.
In Data Connect, the @check
and @redact
directives are most often
used in the context of authorization checks; refer to the
discussion of authorization data lookup.
Add the @check
and @redact
directives to look up authorization data
A common authorization use case involves storing custom authorization roles in your database, for example in a special permissions table, and using those roles to authorize mutations to create, update, or delete data.
Using authorization data lookups, you can query for roles based on a userID and
use CEL expressions to decide if the mutation is authorized. For example, you
might want to write an UpdateMovieTitle
mutation that lets an authorized
client update movie titles.
For the rest of this discussion, assume the movie review app database stores an
authorization role in a MoviePermission
table.
# MoviePermission
# Suppose a user has an authorization role with respect to records in the Movie table
type MoviePermission @table(key: ["doc", "userId"]) {
movie: Movie! # implies another field: movieId: UUID!
userId: String! # Can also be a reference to a User table, doesn't matter
role: String!
}
In the following example implementation, the UpdateMovieTitle
mutation
includes a query
field to retrieve data from MoviePermission
, and the
following directives to ensure the operation is secure and robust:
- A
@transaction
directive to ensure all authorization queries and checks are completed or fail atomically. - The
@redact
directive to omit query results from the response, meaning our authorization check is performed on the Data Connect server but sensitive data is not exposed to the client. A pair of
@check
directives to evaluate authorization logic on query results, such as testing that a given userID has an appropriate role to make modifications.
mutation UpdateMovieTitle($movieId: UUID!, $newTitle: String!) @auth(level: USER) @transaction {
# Step 1: Query and check
query @redact {
moviePermission( # Look up a join table called MoviePermission with a compound key.
key: {movieId: $movieId, userId_expr: "auth.uid"}
# Step 1a: Use @check to test if the user has any role associated with the movie
# Here the `this` binding refers the lookup result, i.e. a MoviePermission object or null
# The `this != null` expression could be omitted since rejecting on null is default behavior
) @check(expr: "this != null", message: "You do not have access to this movie") {
# Step 1b: Check if the user has the editor role for the movie
# Next we execute another @check; now `this` refers to the contents of the `role` field
role @check(expr: "this == 'editor'", message: "You must be an editor of this movie to update title")
}
}
# Step 2: Act
movie_update(id: $movieId, data: {
title: $newTitle
})
}
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 initial prototyping this way, begin to switch from
NO_ACCESS
to production-ready authorization with PUBLIC
and USER
levels.
However, don't 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 using 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)
and @check(expr)
As shown in examples elsewhere in this guide, you can and should use
expressions defined in Common Expression Language (CEL) to control authorization
for Data Connect using @auth(expr:)
and @check
directives.
This section covers CEL syntax relevant to creating expressions for these directives.
Complete reference information for CEL is provided in the CEL specification.
Test variables passed in queries and mutations
@auth(expr)
syntax allows you access and test variables from queries and
mutations.
For example, you can include an operation variable, such as $status
, using
vars.status
.
mutation Update($id: UUID!, $status: Any) @auth(expr: "has(vars.status)")
Data available to expressions
Both @auth(expr:)
and @check(expr:)
CEL expressions can evaluate the
following:
request.operationName
vars
(alias forrequest.variables
)auth
(alias forrequest.auth
)
Additionally, @check(expr:)
expressions can evaluate:
this
(the value of the current field)
The request.operationName object
The request.operarationName
object stores the type of operation, either query
or mutation.
The vars
object
The vars
object allows your expressions to access all variables
passed in your query or mutation.
You can use vars.<variablename>
in an expression as an alias for the
fully-qualified request.variables.<variablename>
:
# The following are equivalent
mutation StringType($v: String!) @auth(expr: "vars.v == 'hello'")
mutation StringType($v: String!) @auth(expr: "request.variables.v == 'hello'")
The auth
object
Authentication identifies users requesting access to your data and provides that information as an object you can build on in your expressions.
In your filters and expressions, you can use auth
as an alias for
request.auth
.
The auth object contains the following information:
uid
: A unique user ID, assigned to the requesting user.token
: A map of values collected by Authentication.
For more details on the contenst of auth.token
see
Data in auth tokens
The this
binding
The binding this
evaluates to the field that the @check
directive
is attached to. In a basic case, you might evaluate single-valued query
results.
mutation UpdateMovieTitle($movieId: UUID!, $newTitle: String!) @auth(level: USER) @transaction {
# Step 1: Query and check
query @redact {
moviePermission( # Look up a join table called MoviePermission with a compound key.
key: {movieId: $movieId, userId_expr: "auth.uid"}
) {
# Check if the user has the editor role for the movie. `this` is the string value of `role`.
# If the parent moviePermission is null, the @check will also fail automatically.
role @check(expr: "this == 'editor'", message: "You must be an editor of this movie to update title")
}
}
# Step 2: Act
movie_update(id: $movieId, data: {
title: $newTitle
})
}
If the returned field occurs multiple times because any ancestor is a list, each
occurrence is tested with this
bound to each value.
For any given path, if an ancestor is null
or []
, the field won't be
reached and the CEL evaluation will be skipped for that path. In other words,
evaluation only takes place when this
is null
or non-null
, but never
undefined
.
When the field itself is a list or object, this
follows the same structure
(including all decendents selected in case of objects), as illustrated in the
following example.
mutation UpdateMovieTitle2($movieId: UUID!, $newTitle: String!) @auth(level: USER) @transaction {
# Step 1: Query and check
query {
moviePermissions( # Now we query for a list of all matching MoviePermissions.
where: {movieId: {eq: $movieId}, userId: {eq_expr: "auth.uid"}}
# This time we execute the @check on the list, so `this` is the list of objects.
# We can use the `.exists` macro to check if there is at least one matching entry.
) @check(expr: "this.exists(p, p.role == 'editor')", message: "You must be an editor of this movie to update title") {
role
}
}
# Step 2: Act
movie_update(id: $movieId, data: {
title: $newTitle
})
}
Complex expression syntax
You can write more complex expressions by combining with the &&
and ||
operators.
mutation UpsertUser($username: String!) @auth(expr: "(auth != null) && (vars.username == 'joe')")
The following section describes all available operators.
Operators and operator precedence
Use the following table 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<b a<=b |
Relational operators | left to right |
a in b |
Existence in list or map | left to right |
type(a) == t |
Type comparison, where t can be bool, int, float,
number, string, list, map, timestamp, or duration |
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 in auth tokens
The auth.token
object may contain the following 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. For example, 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.
|
<claims> (optional) |
Optional custom claims to include in token, which can be accessed through
auth.token (or request.auth.token ) in
expressions. For example, if you create a custom claim
adminClaim , you can access it with
auth.token.adminClaim .
|
What's next?
- Firebase Data Connect provides an Admin SDK to let you perform queries and mutations from privileged environments.
- Learn about IAM security in the guide for managing services and databases.