UUID as Primary Key with Phoenix LiveView Authentication

Phoenix Framework has a potent generator for generating authentication for a project. The mix phx.gen.auth command can generate a complete user authentication system with registration, login, email confirmation, and password reset. But by default, it uses bigserial as the primary key. Using a sequence has drawbacks since it is a predictable counter and anyone can easily know or traverse the service for all available users. For this reason, we sometimes like to use UUID for the primary key.

Today, I’ll go through how to change the default Phoenix behavior of having bigserial as the primary key of the user table. If you don’t have Phoenix installed, check out the official documentation for how to install it.

Let’s generate a new Phoenix application to test things out:

mix phx.new demoUUIDAuth

The command will generate a new Phoenix application with Postgresql as a database. Now let’s change the DB configs in config/dev.exs so that Phoenix can connect with our local running database. After that we can generate the LiveView Authentication with the following command:

mix phx.gen.auth Accounts User users --hashing-lib argon2

The --hashing-lib argon2 tells Phoenix which hashing function to use for the password hashing. Available options are, bcrypt, argon2, and pbkdf2. By default, it uses bcrypt. For more information check out the official documentation. After running the command, you’ll be prompted to run the migration and to get the dependencies. Let’s get the dependencies but hold on running the migration because we need to change a few things to have UUID as our user table primary key. Run mix deps.get to get the dependencies, you’ll see it’ll get the argon2 and comeonin packages.

Since we have the packages, now we can change the schema to tell Ecto that we want to use a different type of primary key for our users table. Ecto here acts as the data mapper and database adapter. We can start using UUID as the primary key for the users table by changing the lib/demoUUIDAuth/accounts/user.ex file like the following:

+    @primary_key {:id, :binary_id, autogenerate: true}
+    @derive {Phoenix.Param, key: :id}
     schema "users" do
        field :email, :string
        field :password, :string, virtual: true, redact: true
        field :hashed_password, :string, redact: true
        field :confirmed_at, :naive_datetime

        timestamps(type: :utc_datetime)
  end

By adding the @primary_key attribute, we’re telling our application that the primary key for the schema will be named id and it is of type binary and it’s autogenerated. Ecto v2 guarantees autogeneration of the primary key either by invoking the database function or generation in the adapter. Meaning, either the ID will be generated on the database or it will be generated automatically by the database adapter layer and sent to the database while inserting.

Now, we have to change the users table definition in the migration file. Open your migration file at priv/repo/migrations/20240224203145_create_users_auth_tables.exs and change the following:

-   create table(:users) do
+   create table(:users, primary_key: false) do
+     add :id, :uuid, primary_key: true
      add :email, :citext, null: false
      add :hashed_password, :string, null: false
      add :confirmed_at, :naive_datetime
      timestamps(type: :utc_datetime)
    end

This change should have been enough if the user_tokens table didn’t have a relationship with the users table. But here the user_tokens table has a reference of users.id as user_id. We also have to change the type of user_tokens.user_id . Since we’re already on the migration file, let’s change the type of user_id field by doing the following:

-   add :user_id, references(:users, on_delete: :delete_all), null: false
+   add :user_id, references(:users, type: :uuid, on_delete: :delete_all), null: false

Basically, we’re saying the type on the reference. But this is not enough, since our schema of user_tokens still doesn’t know the type of user_id field. We have to do the following changes in the lib/taskchecklist/accounts/user_token.ex file:

   schema "users_tokens" do
     field :token, :binary
     field :context, :string
     field :sent_to, :string
 -   belongs_to :user, DemoUUIDAuth.Accounts.User
 +   belongs_to :user, DemoUUIDAuth.Accounts.User, type: :binary_id

     timestamps(updated_at: false)
   end

Now, we’re done with our changes and ready to run the mix ecto.migrate / mix ecto.setup command to run our database migrations. After the successful run of the migration we can start the Phoenix app with the following command:

mix phx.server

This will start our Phoenix application in localhost:4000 by default. To register a new user, go to http://localhost:4000/users/register and register your user. If you look into the logs, you’ll see the id for the user is UUID. You can also verify this by IEx. Run the following commands:

$ iex -S mix
iex(1)> DemoUUIDAuth.Repo.all(DemoUUIDAuth.Accounts.User)
[debug] QUERY OK source="users" db=5.3ms decode=1.1ms queue=2.6ms idle=1240.8ms
SELECT u0."id", u0."email", u0."hashed_password", u0."confirmed_at", u0."inserted_at", u0."updated_at" FROM "users" AS u0 []
↳ :elixir.eval_external_handler/3, at: src/elixir.erl:396
[
  #DemoUUIDAuth.Accounts.User<
    __meta__: #Ecto.Schema.Metadata<:loaded, "users">,
    id: "7d715492-3360-4a74-820e-2d9f17d772bd",
    email: "[email protected]",
    confirmed_at: nil,
    inserted_at: ~U[2024-02-24 21:26:12Z],
    updated_at: ~U[2024-02-24 21:26:12Z],
    ...
  >
]
iex(2)>

Congratulations! You’ve successfully changed the primary key for the Users table from bigserial to UUID. This might feel like a long process but it’s not that bad considering I’ve included all steps of generating the authentication part. The actual change of bigserial to UUID only takes changes in 3 files. You can checkout the repository of this article on GitHub.

If you have any questions, feel free to comment on this post, or reach out to me via LinkedIn or Email ([email protected]). Thanks for reading!

Configure Django Settings for Multiple Environments

For experienced devs, this is a no-brainer but if you’re a new Django developer like me and you have different settings for prod, staging, and local development then you might be thinking about how to have Django Settings for Multiple Environments. Like all of the posts in this blog, this is written primarily to save my time in the future, but at the same time, if it helps someone else, that’s great!!!

If you google the same question, you’ll come across multiple approaches that are very different. If I had to massively generalize them, then I’d put them in the following categories:

  1. Having separate settings files named settings_local.py, settings_local.py, etc. and then pass the settings with --settings flag in manage.py call
  2. Having a module settings_split or similar name and in that module base_settings.py, prod_settings.py etc. while settings.py has the logic for correctly importing the right settings based on the environment variable
  3. Having a module named settings (Django by default looks for this), the module has the base_settings.py, prod_settings.py etc. and the __init__.py has the logic for choosing the right settings

To split Django settings for different environments, I like the last approach best. Because the files are located together in a module and the module name is the exact one that Django looks for by default. It also doesn’t involve any extra steps while using the manage.py commands or modifying the manage.py file. All these settings are in my version control, so whenever I start working on a different machine, I just have to edit the .envrc.sample file and put appropriate environment variables, and it’s all up and running. The 2nd approach is also good, but I really don’t think the settings.py file should just be hanging there in the project and only have logic while the actual settings are somewhere else.

I follow the following best practices while working on any Django project:

  • Have settings that are unique to the machine running the project like Database config, secret keys, API keys, etc on a .envrc file (on my dev machine I use tools like https://direnv.net/ to load in the environment variables from the .envrc file). I make sure NOT TO CHECK THIS FILE OUT WITH GIT and would usually provide a .envrc.sample checked into git.
  • Create a new directory in my Django project module named settings and copy the contents of settings.py in settings/base.py then delete the settings.py file.
  • Create settings/__init__.py file so that settings directory is recognized as a module and has the following logic for loading in the right settings:
import os

from django.core.exceptions import ImproperlyConfigured

ENVS = ["DEV", "PROD", "STAGING"]

env = os.getenv("ENV")

if env not in ENVS:
    error_message = "The currnet 'ENV' is {env} but must be one of {ENVS}"
    raise ImproperlyConfigured(error_message)

# match is a new keyword in Python 3.10, if your python version is less than 3.10, re-write this block with if-else
match env:
    case "DEV":
        from .dev import *
    case "PROD":
        from .prod import *
    case "STAGING":
        from .staging import *
  • Create different settings files for the different environments you want to define inside the settings directory, e.g. dev.py, prod.py etc. Example of a sample dev.py and prod.py:
# Example dev.py
from .base import *


DEBUG = True

ALLOWED_HOSTS = ["localhost", "127.0.0.1"]

INSTALLED_APPS += ["debug_toolbar"] # You can add extra apps on the individual environment.  
# Example prod.py
import os
from .base import *

DEBUG = False

ALLOWED_HOSTS = ["iammahir.com"]

SECRET_KEY = os.getenv("SECRET_KEY")
  • Don’t forget to set ENV environment variable for this all to work, you can either add export ENV="DEV" in your .envrc or .bashrc or .zshrc depending on the setup.

    We’re all set! Everything should work, if you have any questions or trouble making this work or have any opinion about this approach, feel free to look at my very much work-in-progress project or reach out to me at [email protected] or @m4hi2 on all platforms 🙂