Self-hosted Authentication (dbAuth)
Redwood's own dbAuth provides several benefits:
- Use your own database for storing user credentials
- Use your own login, signup and forgot password pages (or use Redwood's pre-built ones)
- Customize login session length
- No external dependencies
- No user data ever leaves your servers
- No additional charges/limits based on number of users
- No third party service outages affecting your site
And potentially one large drawback:
- Use your own database for storing user credentials
However, we're following best practices for storing these credentials:
- Users' passwords are salted and hashed with PBKDF2 before being stored
- Plaintext passwords are never stored anywhere, and only transferred between client and server during the login/signup phase (and hopefully only over HTTPS)
- Our logger scrubs sensitive parameters (like
password
) before they are output - We only store the hashes of reset tokens
Even if you later decide you want to let someone else handle your user data for you, dbAuth is a great option for getting up and running quickly (we even have a generator for creating basic login and signup pages for you).
How It Works
dbAuth relies on good ol' fashioned cookies to determine whether a user is logged in or not. On an attempted login, a serverless function on the api-side checks whether a user exists with the given username (internally, dbAuth refers to this field as username but you can use anything you want, like an email address). If a user with that username is found, does their salted and hashed password match the one in the database?
If so, an HttpOnly, Secure, SameSite cookie (dbAuth calls this the "session cookie") is sent back to the browser containing the ID of the user. The content of the cookie is a simple string, but AES encrypted with a secret key (more on that later).
When the user makes a GraphQL call, we decrypt the cookie and make sure that the user ID contained within still exists in the database. If so, the request is allowed to proceed.
If there are any shenanigans detected (the cookie can't be decrypted properly, or the user ID found in the cookie does not exist in the database) the user is immediately logged out by expiring the session cookie.
Setup
A single CLI command will get you everything you need to get dbAuth working, minus the actual login/signup pages:
yarn rw setup auth dbAuth
You will be prompted to ask if you want to enable WebAuthn support. WebAuthn is an open standard for allowing authentication from devices like TouchID, FaceID, USB fingerprint scanners, and more. If you think you want to use WebAuthn, enter y
at this prompt and read on configuration options.
You can also add WebAuthn to an existing dbAuth install. Read more about WebAuthn usage and config below.
Read the post-install instructions carefully as they contain instructions for adding database fields for the hashed password and salt, as well as how to configure the auth serverless function based on the name of the table that stores your user data. Here they are, but could change in future releases (these do not include the additional WebAuthn required options, make sure you get those from the output of the setup
command):
You will need to add a couple of fields to your User table in order to store a hashed password and salt:
model User {
id Int @id @default(autoincrement())
email String @unique
hashedPassword String // <─┐
salt String // <─┼─ add these lines
resetToken String? // <─┤
resetTokenExpiresAt DateTime? // <─┘
}If you already have existing user records you will need to provide a default value or Prisma complains, so change those to:
hashedPassword String @default("")
salt String @default("")You'll need to let Redwood know what field you're using for your users'
id
andusername
fields In this case we're usingid
andauthFields
config in/api/src/functions/auth.js
(this is also the place to tell Redwood if you used a different name for thehashedPassword
orsalt
fields):authFields: {
id: 'id',
username: 'email',
hashedPassword: 'hashedPassword',
salt: 'salt',
resetToken: 'resetToken',
resetTokenExpiresAt: 'resetTokenExpiresAt',
},To get the actual user that's logged in, take a look at
getCurrentUser()
in/api/src/lib/auth.js
. We default it to something simple, but you may use different names for your model or unique ID fields, in which case you need to update those calls (instructions are in the comment above the code).Finally, we created a
SESSION_SECRET
environment variable for you in.env
. This value should NOT be checked into version control and should be unique for each environment you deploy to. If you ever need to log everyone out of your app at once change this secret to a new value. To create a new secret, run:yarn rw g secret
Need simple Login, Signup and Forgot Password pages? Of course we have a generator for those:
yarn rw generate dbAuth
Note that if you change the fields named hashedPassword
and salt
, and you have some verbose logging in your app, you'll want to scrub those fields from appearing in your logs. See the Redaction docs for info.
Scaffolding Login/Signup/Forgot Password Pages
If you don't want to create your own login, signup and forgot password pages from scratch we've got a generator for that:
yarn rw g dbAuth
Once again you will be asked if you want to create a WebAuthn-enabled version of the LoginPage. If so, enter y
and follow the setup instructions.
The default routes will make them available at /login
, /signup
, /forgot-password
, and /reset-password
but that's easy enough to change. Again, check the post-install instructions for one change you need to make to those pages: where to redirect the user to once their login/signup is successful.
If you'd rather create your own, you might want to start from the generated pages anyway as they'll contain the other code you need to actually submit the login credentials or signup fields to the server for processing.
Configuration
Almost all config for dbAuth lives in api/src/functions/auth.js
in the object you give to the DbAuthHandler
initialization. The comments above each key will explain what goes where. Here's an overview of the more important options:
allowedUserFields
allowedUserFields: ['id', 'email']
Most of the auth handlers accept a user
argument that you can reference in the body of the function. These handlers also sometimes return that user
object. As a security measure, allowedUserFields
defines the only properties that will be available in that object so that sensitive data isn't accidentally leaked by these handlers to the client.
The signup
and forgotPassword
handlers return to the client whatever data is returned from their handlers, which can be used to display something like the email address that a verification email was just sent to. Without allowedUserFields
it would be very easy to include the user's hashedPassword
and salt
in that response (just return user
from those handlers) and then any customer could open the Web Inspector in their browser and see those values in plain text!
allowedUserFields
is defaulted to id
and email
but you can add any property on user
to that list.
login.enabled
Allow users to call login. Defaults to true. Needs to be explicitly set to false to disable the flow.
login: {
enabled: false
}
login.handler()
If you want to do something other than immediately let a user log in if their username/password is correct, you can add additional logic in login.handler()
. For example, if a user's credentials are correct, but they haven't verified their email address yet, you can throw an error in this function with the appropriate message and then display it to the user. If the login should proceed, simply return the user that was passed as the only argument to the function:
login: {
handler: (user) => {
if (!user.verified) {
throw new Error('Please validate your email first!')
} else {
return user
}
}
}
signup.enabled
Allow users to sign up. Defaults to true. Needs to be explicitly set to false to disable the flow.
signup: {
enabled: false
}
signup.handler()
This function should contain the code needed to actually create a user in your database. You will receive a single argument which is an object with all of the fields necessary to create the user (username
, hashedPassword
and salt
) as well as any additional fields you included in your signup form in an object called userAttributes
:
signup: {
handler: ({ username, hashedPassword, salt, userAttributes }) => {
return db.user.create({
data: {
email: username,
hashedPassword: hashedPassword,
salt: salt,
name: userAttributes.name,
},
})
}
}
Before signup.handler()
is invoked, dbAuth will check that the username is unique in the database and throw an error if not.
There are three things you can do within this function depending on how you want the signup to proceed:
- If everything is good and the user should be logged in after signup: return the user you just created
- If the user is safe to create, but you do not want to log them in automatically: return a string, which will be returned by the
signUp()
function you called after destructuring it fromuseAuth()
(see code snippet below) - If the user should not be able to sign up for whatever reason: throw an error in this function with the message to be displayed
You can deal with case #2 by doing something like the following in a signup component/page:
const { signUp } = useAuth()
const onSubmit = async (data) => {
const response = await signUp({ ...data })
if (response.message) {
toast.error(response.message) // user created, but not logged in
} else {
toast.success('Welcome!') // user created and logged in
navigate(routes.dashboard())
}
}
signup.passwordValidation()
This function is used to validate that the password supplied at signup meets certain criteria (length, randomness, etc.). By default it just returns true
which means the password is always considered valid, even if only a single character (dbAuth features built-in validation that the password is not blank, an empty string, or made up of only spaces). Modify it to enforce whatever methodology you want on the password.
If the password is valid, return true
. Otherwise, throw the PasswordValidationError
along with a (optional) message explaining why:
signup: {
passwordValidation: (password) => {
if (password.length < 8) {
throw new PasswordValidationError(
'Password must be at least 8 characters'
)
}
if (!password.match(/[A-Z]/)) {
throw new PasswordValidationError(
'Password must contain at least one capital letter'
)
}
return true
}
}
For the best user experience you should include the same checks on the client side and avoid the roundtrip to the server altogether if the password is invalid. However, having the checks here makes sure that someone can't submit a user signup programmatically and skirt your password requirements.
forgotPassword.enabled
Allow users to request a new password via a call to forgotPassword
. Defaults to true. Needs to be explicitly set to false to disable the flow.
When disabling this flow you probably want to disable resetPassword
as well.
forgotPassword: {
enabled: false
}
forgotPassword.handler()
This handler is invoked if a user is found with the username/email that they submitted on the Forgot Password page, and that user will be passed as an argument. Inside this function is where you'll send the user a link to reset their password—via an email is most common. The link will, by default, look like:
https://example.com/reset-password?resetToken=${user.resetToken}
If you changed the path to the Reset Password page in your routes you'll need to change it here. If you used another name for the resetToken
database field, you'll need to change that here as well:
https://example.com/reset-password?resetKey=${user.resetKey}
Note that although the user table contains a hash of
resetToken
, only for the handler,user.resetToken
will contain the rawresetToken
to use for generating a password reset link.