Go Back

Supabase Auth Without a Framework

In my last post I demonstrated that you can use Supabase with any language thanks to the Postgrest Rest APIs. Today, I'm going to show you how to make use of this API to handle a typical Auth flow. I'll cover signing up, email verification, and signing in with email and password.

Getting Started

In a new Supabase project, email and password authentication is enabled by default. We'll be using the PKCE Flow (Proof Key for Code Exchange) which is documented here. The steps we'll follow are the same, but we'll be using the REST API instead of a client library.

If you are self-hosting you will need to enable email verification as it's turned off by default.

Step 1. Supabase Confirmation Emails

In order to not get stuck at the Email Verification step, we need to adjust the Email Templates on Supabase so that the web server can extract the Token Hash, and then send it on to Supabase to complete the email verification.

If you plan to run Supabase in production you will need an SMTP provider like Mailgun or Sendgrid.

Change Email Templates

Head to your Supabase dashboard, then Auth > Email Templates.

Per the docs, we will change our Confirm Signup template to include the Token Hash as a URL Parameter to the verification link, like this:

We will be using localhost for demonstration purposes.

<h2>Confirm your signup</h2>

<p>Follow this link to confirm your user:</p>
<p>
  <a href="{{ .SiteURL }}/auth/confirm?token_hash={{ .TokenHash }}&type=email"
    >Confirm your email</a>
</p>

2. Sign Up

With that detail out of the way, we're ready to get to the code.

We need a web server, and we need to create a route to receive requests. This route will be receiving JSON, so in the route handler we'll use Axum's Json Extractor.

The request looks like this:

curl -X POST 'https://phcccknqeiahpzgtxfiq.supabase.co/auth/v1/signup' \
-H "apikey: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6InBoY2Nja25xZWlhaHB6Z3R4ZmlxIiwicm9sZSI6ImFub24iLCJpYXQiOjE3MjMxNjAxMTAsImV4cCI6MjAzODczNjExMH0.BPjOKZ5fKoZO15PYIOF2EkN1riCsJHXrWdYwXOYFdbY" \
-H "Content-Type: application/json" \
-d '{
  "email": "demo@demo.com",
  "password": "qwerqwer"
}'

I'll show you how to do this in Rust with Axum:

#[tokio::main]
async fn main() {
    let app = axum::Router::new()
		// Add our sign up route here 👇
        .route("/auth/sign_up", axum::routing::post(sign_up_with_email));

    // run our app with hyper, listening globally on port 3000
    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

#[derive(serde::Deserialize)]
struct SignUpParams {
    email: String,
    password: String,
}

async fn sign_up_with_email(Json(payload): Json<SignUpParams>) {
    let email = &payload.email;
    let password = &payload.password;
    let mut headers = header::HeaderMap::new();

    // Removed the long keys for readability
    headers.insert("Content-Type", "application/json".parse().unwrap());
    headers.insert("apikey", "<your api key>".parse().unwrap());

    // If this looks odd, it's because the format macro takes some getting used to
    let body = format!(r#"{{"email": "{}", "password": "{}"}}"#, email, password);
    println!("{body}");

    let client = reqwest::Client::new();

    let res = client
        .post("https://phcccknqeiahpzgtxfiq.supabase.co/auth/v1/signup")
        .headers(headers)
        .body(body)
        .send()
        .await
        .unwrap()
        .text() // Decodes the response body into text
        .await
        .unwrap();

    // TODO: Send a response to our client
    println!("response: {}", res);
}

Now that we've created the endpoint, we need to test it out. Run your web server with cargo run and then send your post request to localhost:3000/auth/sign_up.

My tool of choice for this used to be Postman, but these days I've been using Hoppscotch. It's the same thing, except it is open source and hasn't been enshittified by VCs. Of course, you could do this with curl, or one of many other tools like Insomnia or Thunder Client.

Now send your request, and if you gave it a real email address, Supabase will send you an email. Two steps done already!

3. Confirm Email

Now that we can tell Supabase a user has signed up, we need to be able to do something when that user clicks the verification link in their email. Let's create another route!

This route will extract the URL Parameters we added in Step 1 when a user clicks the verify link. We then add the extracted parameters to the POST request we'll send to Supabase. Just like with the Json Extractor from the last step, Axum has a Query Extractor we can use to make this easy.

curl -X POST 'https://phcccknqeiahpzgtxfiq.supabase.co/auth/v1/verify' \
-H "apikey: SUPABASE_KEY" \
-H "Content-Type: application/json" \
-d '{
  "type": "email",
  "token_hash": <the token hash we extracted>
}'

Now we will need to add the confirmation route to the router, like so:

#[tokio::main]
async fn main() {
    let app = axum::Router::new()
        .route("/auth/sign_up", axum::routing::post(sign_up_with_email))
        // Added the confirm route here 👇
		.route("/auth/confirm", axum::routing::get(confirm_email));

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

Once we've added the route, we need to create a handler. It's almost identical to the handler for the Sign Up function above, so you can probably already see a pattern emerging!

#[derive(serde::Deserialize)]
struct ConfirmEmail {
    token_hash: String,
}

async fn confirm_email(confirm: Query<ConfirmEmailParams>) {
    let token_hash = &confirm.token_hash;
    let mut headers = header::HeaderMap::new();

    headers.insert("Content-Type", "application/json".parse().unwrap());
    headers.insert("apikey", "<your api key>".parse().unwrap());

	// We just hardcoded the email type 👇, but you could extract it 
    let body = format!(r#"{{"type": "email", "token_hash": "{}"}}"#, token_hash);

    let client = reqwest::Client::new();

    let res = client
        .post("https://phcccknqeiahpzgtxfiq.supabase.co/auth/v1/verify")
        .headers(headers)
        .body(body)
        .send()
        .await
        .unwrap()
        .text() // Decodes the response body into text
        .await
        .unwrap();

    // TODO: Send a response to our client
    println!("{}", res);
}

With that, you can now test your Sign Up and Confirm functions:

  1. Sign Up - Send a request to http://localhost:3000/auth/sign_up. Make sure to include Content-Type: application/json as a header, and a body that is correctly formatted in JSON.
  2. Open your Supabase dashboard then go to Auth where it will display a list of users. The user you just signed up with should be there, but not verified.
  3. Check your email, and click the link which should take you to - http://localhost:3000/auth/confirm?token_hash=<some token hash>
  4. Check the Auth page once again, and hit Reload (next to the green Add User button!) if the user's email hasn't been confirmed yet.

You will see that your new user is verified! Now all we have to do is log in and we have a basic authentication flow working.

4. Sign In

With those steps complete we're down to the last step in our basic authentication flow. Just like before, we need to create a route to Sign In. Here's that snippet:

#[tokio::main]
async fn main() {
    let app = axum::Router::new()
        .route("/auth/sign_up", axum::routing::post(sign_up_with_email))
		.route("/auth/confirm", axum::routing::get(confirm_email));
        // Added the sign in route here 👇
		.route("/auth/sign_in", axum::routing::post(sign_in))

    let listener = tokio::net::TcpListener::bind("0.0.0.0:3000").await.unwrap();
    axum::serve(listener, app).await.unwrap();
}

As you probably anticipated, we will add a handler to take in that request and forward the login details to Supabase. First, let's look at the request:


curl -X POST 'https://phcccknqeiahpzgtxfiq.supabase.co/auth/v1/token?grant_type=password' \
-H "apikey: <your api key>" \
-H "Content-Type: application/json" \
-d '{
  "email": "someone@email.com",
  "password": "AetvwAWOWXzWnFFmJItq"
}'

Looks pretty familiar, right? Now let's add our handler:

#[derive(serde::Deserialize)]
struct SignInParams {
    email: String,
    password: String,
}

// Parses the payload into the struct above
async fn sign_in(Json(payload): Json<SignInParams>) {
    let email = &payload.email;
    let password = &payload.password;
    let mut headers = header::HeaderMap::new();

    headers.insert("Content-Type", "application/json".parse().unwrap());
    headers.insert("apikey", "<your api key>".parse().unwrap());

    let body = format!(r#"{{"email": "{}", "password": "{}"}}"#, email, password);

    let client = reqwest::Client::new();

    let res = client
        .post("https://phcccknqeiahpzgtxfiq.supabase.co/auth/v1/token?grant_type=password")
        .headers(headers)
        .body(body)
        .send()
        .await
        .unwrap()
        .text() // Decodes the response body into text
        .await
        .unwrap();

    // TODO: Send a response to our client
    println!("{}", res);
}

Assuming you've sent the correct credentials in your POST request, you will be treated with a JSON object like this one:

{
  "access_token": "eyJhbGciOiJIUzI1NiIsImtpZCI6Ik53SXhTRkZLbjdRVXZaVDQiLCJ0eXAiOiJKV1QifQ.eyJpc3MiOiJodHRwczovL3BoY2Nja25xZWlhaHB6Z3R4ZmlxLnN1cGFiYXNlLmNvL2F1dGgvdjEiLCJzdWIiOiIzN2QyODU4OS05Y2YzLTRiMGUtOThkOS0zODk2MmFkZmVhYzMiLCJhdWQiOiJhdXRoZW50aWNhdGVkIiwiZXhwIjoxNzIzMTc4MTExLCJpYXQiOjE3MjMxNzQ1MTEsImVtYWlsIjoiZGVtb0BkZW1vLmNvbSIsInBob25lIjoiIiwiYXBwX21ldGFkYXRhIjp7InByb3ZpZGVyIjoiZW1haWwiLCJwcm92aWRlcnMiOlsiZW1haWwiXX0sInVzZXJfbWV0YWRhdGEiOnt9LCJyb2xlIjoiYXV0aGVudGljYXRlZCIsImFhbCI6ImFhbDEiLCJhbXIiOlt7Im1ldGhvZCI6InBhc3N3b3JkIiwidGltZXN0YW1wIjoxNzIzMTc0NTExfV0sInNlc3Npb25faWQiOiJiZmRkMGM3MC1iNGFkLTRiN2ItOWU2OC0wMjdiOTNhOWQ2ODIiLCJpc19hbm9ueW1vdXMiOmZhbHNlfQ.Y9SQ_CRSyiT0tQ-n46s1uJzenGLyloMy751Si0hjy7E",
  "token_type": "bearer",
  "expires_in": 3600,
  "expires_at": 1723178111,
  "refresh_token": "LYpYnoEfS-z6ykTgoxWLLw",
  "user": {
    "id": "37d28589-9cf3-4b0e-98d9-38962adfeac3",
    "aud": "authenticated",
    "role": "authenticated",
    "email": "demo@demo.com",
    "email_confirmed_at": "2024-08-09T03:32:42.879506Z",
    "phone": "",
    "confirmed_at": "2024-08-09T03:32:42.879506Z",
    "last_sign_in_at": "2024-08-09T03:35:11.249835701Z",
    "app_metadata": {
      "provider": "email",
      "providers": [
        "email"
      ]
    },
    "user_metadata": {},
    "identities": [
      {
        "identity_id": "a7bd02bf-27f2-435b-8383-4b834ee2465b",
        "id": "37d28589-9cf3-4b0e-98d9-38962adfeac3",
        "user_id": "37d28589-9cf3-4b0e-98d9-38962adfeac3",
        "identity_data": {
          "email": "demo@demo.com",
          "email_verified": false,
          "phone_verified": false,
          "sub": "37d28589-9cf3-4b0e-98d9-38962adfeac3"
        },
        "provider": "email",
        "last_sign_in_at": "2024-08-09T03:32:42.87685Z",
        "created_at": "2024-08-09T03:32:42.876906Z",
        "updated_at": "2024-08-09T03:32:42.876906Z",
        "email": "demo@demo.com"
      }
    ],
    "created_at": "2024-08-09T03:32:42.87307Z",
    "updated_at": "2024-08-09T03:35:11.25505Z",
    "is_anonymous": false
  }
}

We get an access_token as well as a refresh_token. These tokens are all you need to manage sessions with Supabase. By default, Supabase sessions do not expire. This means you can initiate a refresh using the refresh_token at any time.

Wrapping Up

In about 100 lines of code we've implemented a basic authentication flow using Supabase's REST API. Now you have the tools to use Supabase Auth to build secure, authenticated applications without relying on Supabase's client libraries.

From here you would want to implement session management, and send real responses back to your users instead of just println!(). In particular, I recommend looking into tools like HTMX or Unpoly. They are a very small layer of javascript that makes it easy to add reactivity to your front-end and create a nice user experience. Those tools pair well with templating engines like Askama or Tera to make your app lightning fast. You'll be scoring 100s on Lighthouse in no time.

Community Plugs

Once again I must say that this post was not sponsored by Supabase. I simply like the tool, the team who built it, and the community that has gathered around the project.

There's a few things I'd like to plug because I believe it's worth your time to look at if you're interested in Supabase, and I can personally vouch for the people involved.

  1. A really cool dude named David Lorenz, aka @activenode wrote a book that teaches everything you would ever want to know about Supabase. He also does consulting to help people get the most out of the platform. Check out his book.
  2. The Supabase Discord has a lot of helpful people who answer every question you've ever thought of and more. Join, ask questions, and meet other people who build stuff.
  3. The Build In Public 3 community on X is a heavily moderated community that makes sure the conversation stays helpful and positive. If you are into startups or the indie-hacking scene, come join us.