Ben Gorman

Ben Gorman

Life's a garden. Dig it.

Let's implement a Sign up form where users can create an account using their email address and a password.

  1. Install firebase from npm

    $ npm install firebase
    Run this from the working directory: fireauth/
  2. Create the route src/app/signup/page.js

    src/app/signup/page.js
    export default function SignupPage() {
      return <div>Signup page</div>
    }
  3. Create the signup form

    src/app/signup/SignUpForm.js
    "use client"
     
    import { useState } from "react"
     
    export function SignUpForm() {
      const [email, setEmail] = useState("")
      const [password, setPassword] = useState("")
     
      const handleFormSubmit = (event) => {
        event.preventDefault()
     
        console.log("email", email)
        console.log("password", password)
     
        // We'll implement the rest of this in soon...
      }
     
      return (
        <form onSubmit={handleFormSubmit}>
          <label>Email:</label>
          <input
            type="text"
            value={email}
            onChange={(event) => setEmail(event.target.value)}
          />
     
          <label>Password:</label>
          <input
            type="password"
            value={password}
            onChange={(event) => setPassword(event.target.value)}
          />
     
          <button type="submit">Sign up</button>
        </form>
      )
    }

Now, a visit to http://localhost:3000/signup should display the butt-ugly signup form. 🫣 Let's test it out.

Cool. Now let's incorporate the createUserWithEmailAndPassword() Firebase function in the onSubmit form handler.

src/app/signup/SignUpForm.js
"use client"
 
import { auth } from "@/firebase/firebase"
import { createUserWithEmailAndPassword } from "firebase/auth"
import { useState } from "react"
 
export function SignUpForm() {
  const [email, setEmail] = useState("")
  const [password, setPassword] = useState("")
 
  const handleFormSubmit = (event) => {
    event.preventDefault()
 
    createUserWithEmailAndPassword(auth, email, password)
      .then((userCredential) => {
        console.log("userCredential", userCredential)
      })
      .catch((error) => {
        console.log("Error:", error)
      })
  }
 
  return (
    <form onSubmit={handleFormSubmit}>
      <label>Email:</label>
      <input
        type="text"
        value={email}
        onChange={(event) => setEmail(event.target.value)}
      />
 
      <label>Password:</label>
      <input
        type="password"
        value={password}
        onChange={(event) => setPassword(event.target.value)}
      />
 
      <button type="submit">Sign up</button>
    </form>
  )
}

Before I explain how it works, let's see it in action.

Notes

  1. The createUserWithEmailAndPassword() returns a UserCredential instance with a lot of stuff inside it. We'll review that soon.

  2. The Authentication tab of the Emulator Suite shows the created user account.

  3. In the browser console, there's a little info icon next to the userCredential object with a message that says

    This value was evaluated upon first expanding. It may have changed since then.

    value may have changed

    DO NOT IGNORE THIS NOTE! It means that the value you see right now might not be the value that existed 10 seconds ago or 10 seconds in the future. This behavior has caused me a lot of pain and suffering. To avoid this, I find it's best to use

    console.log("someObject", JSON.stringify(someObject))

    rather than

    console.log("someObject", someObject)

    JSON.stringify(someObject) displays someObject as it existed at the time it was logged.

userCredential

A successful invocation of the createUserWithEmailAndPassword() function returns a UserCredential instance. Here's the latest userCredential value returned to me after creating a new user.

userCredential
{
  user: {
    uid: "jTzMOkkkEunEdXK9x29lsjVRu7bJ",
    email: "[email protected]",
    emailVerified: false,
    isAnonymous: false,
    providerData: [
      {
        providerId: "password",
        uid: "[email protected]",
        displayName: null,
        email: "[email protected]",
        phoneNumber: null,
        photoURL: null,
      },
    ],
    stsTokenManager: {
      refreshToken:
        "eyJfQXV0aEVtdWxhdG9yUmVmcmVzaFRva2VuIjoiRE8gTk9UIE1PRElGWSIsImxvY2FsSWQiOiJqVHpNT2tra0V1bkVkWEs5eDI5bHNqVlJ1N2JKIiwicHJvdmlkZXIiOiJwYXNzd29yZCIsImV4dHJhQ2xhaW1zIjp7fSwicHJvamVjdElkIjoiZmlyZWF1dGg1NSJ9",
      accessToken:
        "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJlbWFpbCI6InJpY2tAZ21haWwuY29tIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJhdXRoX3RpbWUiOjE3MjM4MzY1NTcsInVzZXJfaWQiOiJqVHpNT2tra0V1bkVkWEs5eDI5bHNqVlJ1N2JKIiwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJlbWFpbCI6WyJyaWNrQGdtYWlsLmNvbSJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn0sImlhdCI6MTcyMzgzNjU1NywiZXhwIjoxNzIzODQwMTU3LCJhdWQiOiJmaXJlYXV0aDU1IiwiaXNzIjoiaHR0cHM6Ly9zZWN1cmV0b2tlbi5nb29nbGUuY29tL2ZpcmVhdXRoNTUiLCJzdWIiOiJqVHpNT2tra0V1bkVkWEs5eDI5bHNqVlJ1N2JKIn0.",
      expirationTime: 1723840157813,
    },
    createdAt: "1723836557810",
    lastLoginAt: "1723836557810",
    apiKey: "AIzaSyRUHG9ZP-vUWBqkPU5I",
    appName: "[DEFAULT]",
  },
  providerId: null,
  _tokenResponse: {
    kind: "identitytoolkit#SignupNewUserResponse",
    localId: "jTzMOkkkEunEdXK9x29lsjVRu7bJ",
    email: "[email protected]",
    idToken:
      "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJlbWFpbCI6InJpY2tAZ21haWwuY29tIiwiZW1haWxfdmVyaWZpZWQiOmZhbHNlLCJhdXRoX3RpbWUiOjE3MjM4MzY1NTcsInVzZXJfaWQiOiJqVHpNT2tra0V1bkVkWEs5eDI5bHNqVlJ1N2JKIiwiZmlyZWJhc2UiOnsiaWRlbnRpdGllcyI6eyJlbWFpbCI6WyJyaWNrQGdtYWlsLmNvbSJdfSwic2lnbl9pbl9wcm92aWRlciI6InBhc3N3b3JkIn0sImlhdCI6MTcyMzgzNjU1NywiZXhwIjoxNzIzODQwMTU3LCJhdWQiOiJmaXJlYXV0aDU1IiwiaXNzIjoiaHR0cHM6Ly9zZWN1cmV0b2tlbi5nb29nbGUuY29tL2ZpcmVhdXRoNTUiLCJzdWIiOiJqVHpNT2tra0V1bkVkWEs5eDI5bHNqVlJ1N2JKIn0.",
    refreshToken:
      "eyJfQXV0aEVtdWxhdG9yUmVmcmVzaFRva2VuIjoiRE8gTk9UIE1PRElGWSIsImxvY2FsSWQiOiJqVHpNT2tra0V1bkVkWEs5eDI5bHNqVlJ1N2JKIiwicHJvdmlkZXIiOiJwYXNzd29yZCIsImV4dHJhQ2xhaW1zIjp7fSwicHJvamVjdElkIjoiZmlyZWF1dGg1NSJ9",
    expiresIn: "3600",
  },
  operationType: "signIn",
}

Notice the user object and other details embedded inside this userCredential.

Network Activity

Now let's see what happens in the Network tab when we create a user...

Interestingly, there are four different requests. Why? πŸ€”

Breakdown

  1. The first request is an OPTIONS request. It looks like this πŸ‘‡

    fetch("http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=AIzaSyRUHG9ZP-vUWBqkPU5I", {
      "headers": {
        "accept": "*/*",
        "accept-language": "en-US,en;q=0.9",
        "cache-control": "no-cache",
        "pragma": "no-cache",
        "sec-fetch-dest": "empty",
        "sec-fetch-mode": "cors",
        "sec-fetch-site": "cross-site"
      },
      "referrerPolicy": "no-referrer",
      "body": null,
      "method": "OPTIONS",
      "mode": "cors",
      "credentials": "omit"
    });

    This is what's called a preflight request. The createUserWithEmailAndPassword() function didn't tell your browser to make this request. It told your browser to make a POST request. But before making the POST request, the browser interjects and says

    Whoa there.. before I make that POST request, I need to make an OPTIONS request. If the server returns the response I expect, then I'll make the POST request you asked for.

    Why does it do this? It's a protection against legacy servers that implemented logic before CORS was a thing. Frankly, you can ignore these preflight requests. But if you're curious, this is a nice StackOverflow thread on the matter.

  2. The second request is a POST request πŸ‘‡

    fetch("http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:signUp?key=AIzaSyRUHG9ZP-vUWBqkPU5I", {
      "headers": {
        "accept": "*/*",
        "accept-language": "en-US,en;q=0.9",
        "cache-control": "no-cache",
        "content-type": "application/json",
        "pragma": "no-cache",
        "sec-ch-ua": "\"Not)A;Brand\";v=\"99\", \"Google Chrome\";v=\"127\", \"Chromium\";v=\"127\"",
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": "\"macOS\"",
        "sec-fetch-dest": "empty",
        "sec-fetch-mode": "cors",
        "sec-fetch-site": "cross-site",
        "x-client-version": "Chrome/JsCore/10.13.0/FirebaseCore-web",
        "x-firebase-gmpid": "1:1569792077:web:7263700fbcb18fa02564"
      },
      "referrerPolicy": "no-referrer",
      "body": "{\"returnSecureToken\":true,\"email\":\"[email protected]\",\"password\":\"hunter1\",\"clientType\":\"CLIENT_TYPE_WEB\"}",
      "method": "POST",
      "mode": "cors",
      "credentials": "omit"
    });

    This is where things get juicy πŸ˜‹. Here, Firebase makes a request to the endpoint identitytoolkit.googleapis.com/v1/accounts:signUp, passing along the email and password in the request body.

    The identity toolkit creates the user account and returns the following response πŸ‘‡

    {
        "kind": "identitytoolkit#SignupNewUserResponse",
        "localId": "rw989AAMzeEiULUS0guvTbL4uaLH",
        "email": "[email protected]",
        "idToken": "eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJlbWFpbCI6Imp1bGlhbkBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsImF1dGhfdGltZSI6MTcyMzg2MDM0OSwidXNlcl9pZCI6InJ3OTg5QUFNemVFaVVMVVMwZ3V2VGJMNHVhTEgiLCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7ImVtYWlsIjpbImp1bGlhbkBnbWFpbC5jb20iXX0sInNpZ25faW5fcHJvdmlkZXIiOiJwYXNzd29yZCJ9LCJpYXQiOjE3MjM4NjAzNDksImV4cCI6MTcyMzg2Mzk0OSwiYXVkIjoiZmlyZWF1dGg1NSIsImlzcyI6Imh0dHBzOi8vc2VjdXJldG9rZW4uZ29vZ2xlLmNvbS9maXJlYXV0aDU1Iiwic3ViIjoicnc5ODlBQU16ZUVpVUxVUzBndXZUYkw0dWFMSCJ9.",
        "refreshToken": "eyJfQXV0aEVtdWxhdG9yUmVmcmVzaFRva2VuIjoiRE8gTk9UIE1PRElGWSIsImxvY2FsSWQiOiJydzk4OUFBTXplRWlVTFVTMGd1dlRiTDR1YUxIIiwicHJvdmlkZXIiOiJwYXNzd29yZCIsImV4dHJhQ2xhaW1zIjp7fSwicHJvamVjdElkIjoiZmlyZWF1dGg1NSJ9",
        "expiresIn": "3600"
    }
  3. The third request is another CORS preflight request. (Boring!)

  4. The fourth request is a another POST request.

    fetch("http://127.0.0.1:9099/identitytoolkit.googleapis.com/v1/accounts:lookup?key=AIzaSyRUHG9ZP-vUWBqkPU5I", {
      "headers": {
        "accept": "*/*",
        "accept-language": "en-US,en;q=0.9",
        "cache-control": "no-cache",
        "content-type": "application/json",
        "pragma": "no-cache",
        "sec-ch-ua": "\"Not)A;Brand\";v=\"99\", \"Google Chrome\";v=\"127\", \"Chromium\";v=\"127\"",
        "sec-ch-ua-mobile": "?0",
        "sec-ch-ua-platform": "\"macOS\"",
        "sec-fetch-dest": "empty",
        "sec-fetch-mode": "cors",
        "sec-fetch-site": "cross-site",
        "x-client-version": "Chrome/JsCore/10.13.0/FirebaseCore-web",
        "x-firebase-gmpid": "1:1569792077:web:7263700fbcb18fa02564"
      },
      "referrerPolicy": "no-referrer",
      "body": "{\"idToken\":\"eyJhbGciOiJub25lIiwidHlwIjoiSldUIn0.eyJlbWFpbCI6Imp1bGlhbkBnbWFpbC5jb20iLCJlbWFpbF92ZXJpZmllZCI6ZmFsc2UsImF1dGhfdGltZSI6MTcyMzg2MDM0OSwidXNlcl9pZCI6InJ3OTg5QUFNemVFaVVMVVMwZ3V2VGJMNHVhTEgiLCJmaXJlYmFzZSI6eyJpZGVudGl0aWVzIjp7ImVtYWlsIjpbImp1bGlhbkBnbWFpbC5jb20iXX0sInNpZ25faW5fcHJvdmlkZXIiOiJwYXNzd29yZCJ9LCJpYXQiOjE3MjM4NjAzNDksImV4cCI6MTcyMzg2Mzk0OSwiYXVkIjoiZmlyZWF1dGg1NSIsImlzcyI6Imh0dHBzOi8vc2VjdXJldG9rZW4uZ29vZ2xlLmNvbS9maXJlYXV0aDU1Iiwic3ViIjoicnc5ODlBQU16ZUVpVUxVUzBndXZUYkw0dWFMSCJ9.\"}",
      "method": "POST",
      "mode": "cors",
      "credentials": "omit"
    });

    This request looks just like the previous POST request, but instead of hitting the accounts:signUp endpoint, it hits the accounts:lookup endpoint. Furthermore, the response is a bit different πŸ‘‡

    {
        "kind": "identitytoolkit#GetAccountInfoResponse",
        "users": [
            {
                "localId": "rw989AAMzeEiULUS0guvTbL4uaLH",
                "lastLoginAt": "1723860349194",
                "emailVerified": false,
                "email": "[email protected]",
                "salt": "fakeSaltpQlj5gIt73DfCwHad8Pg",
                "passwordHash": "fakeHash:salt=fakeSaltpQlj5gIt73DfCwHad8Pg:password=hunter1",
                "passwordUpdatedAt": 1723860349194,
                "validSince": "1723860349",
                "createdAt": "1723860349194",
                "providerUserInfo": [
                    {
                        "providerId": "password",
                        "email": "[email protected]",
                        "federatedId": "[email protected]",
                        "rawId": "[email protected]"
                    }
                ],
                "lastRefreshAt": "2024-08-17T02:05:49.197Z"
            }
        ]
    }

Let's try that again, except this time let's use a mangled email address so that we get an error.

In this case, the first POST request returns the following response headers πŸ‘‡

Response Headers
HTTP/1.1 400 Bad Request
X-Powered-By: Express
Access-Control-Allow-Origin: http://localhost:3000
Vary: Origin
Content-Type: application/json; charset=utf-8
Content-Length: 205
ETag: W/"cd-0JYghHL+cORBSUZggCdEJkfW1z8"
Date: Sat, 17 Aug 2024 02:14:39 GMT
Connection: keep-alive
Keep-Alive: timeout=5

and response body

Response Body
{
  "error": {
    "code": 400,
    "message": "MISSING_PASSWORD",
    "errors": [
      {
        "message": "MISSING_PASSWORD",
        "reason": "invalid",
        "domain": "global"
      }
    ]
  }
}

Furthermore, the second POST request we saw earlier (the one that hits the accounts:lookup endpoint) is not generated.

Application Storage

The Application tab in the Chrome DevTools window shows you all the data that an application stores inside your browser (i.e. client-side storage).

Upon creating a new user, Firebase writes information about the user to an indexed database named firebaseLocalStorage.

firebaseLocalStorage