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.
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:
- Sign Up - Send a request to
http://localhost:3000/auth/sign_up
. Make sure to includeContent-Type: application/json
as a header, and a body that is correctly formatted in JSON. - 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. - Check your email, and click the link which should take you to -
http://localhost:3000/auth/confirm?token_hash=<some token hash>
- Check the
Auth
page once again, and hitReload
(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.
- 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.
- 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.
- 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.