Joonas' blog

Migrating to PKCE-compatible generateLink in Supabase

published

@supabase/auth-helpers-nextjs v.0.7.0 introduced PKCE authentication flow type and made it the default. This in turn broke existing usage of supabase.auth.generateLink. This article explains what changed and how to fix it.

Given code like

const linkResponse = await supabase.auth.admin.generateLink({
type: "magiclink",
email: "my@email.com",
options: {
redirectTo: "/hereicome",
},
});
const loginLink = linkResponse.data.properties.action_link;

it generates a loginLink that looks something like this:

https://supabaseprojectid.supabase.co/auth/v1/verify?token=foobarfoobarfoobarfoobar&type=magiclink&redirect_to=http://example.com/hereicome

Navigating to loginLink then redirects you to the redirect_to path with an access token appended to it:

http://example.com/hereicome#access_token=supabasejwtaccesstokenhere&expires_at=1693650070&expires_in=7200&refresh_token=supabaserefreshtokenhere&token_type=bearer&type=magiclink

In pre-PKCE world, Supabase library’s auth client would detect the hash with access token and automatically update clientside authentication with new access/refresh tokens. This made using generateLink very easy: just give user the generated link directly and let Supabase clientside library handle the rest.

In PKCE authentication flow, the authentication process includes generating a code verifier on the application server and using it in the verification process. This means that we can no longer rely on clientside library to handle everything, and instead need a serverside component to handle the verification.

Let’s start fixing generateLink for PKCE by changing our link generation response handling slightly:

const linkResponse = await supabase.auth.admin.generateLink({
type: "magiclink",
email: "my@email.com",
options: {
redirectTo: "/hereicome",
},
});
const tokenHash = linkResponse.data.properties.hashed_token;

Instead of action_link like previously, we’ll use the hashed token directly this time.

Next up, we’ll have to create a route handler to process our serverside token validation and session handling (from Supabase docs, except with type: "magiclink" hardcoded):

app/auth/confirm/route.ts

import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'
import { cookies } from 'next/headers'
import { NextResponse } from 'next/server'
export async function GET(req) {
const { searchParams } = new URL(req.url)
const token_hash = searchParams.get('token_hash')
const next = searchParams.get('next') ?? '/'
if (token_hash) {
const supabase = createRouteHandlerClient({ cookies })
const { error } = await supabase.auth.verifyOtp({ type: "magiclink", token_hash })
if (!error) {
return NextResponse.redirect(new URL(`/${next.slice(1)}`, req.url))
}
}
// return the user to an error page with some instructions
return NextResponse.redirect(new URL('/auth/auth-code-error', req.url))
}

Finally, we’ll have to build the redirect link to give to the user:

const linkResponse = await supabase.auth.admin.generateLink({
type: "magiclink",
email: "my@email.com"
});
const tokenHash = linkResponse.data.properties.hashed_token;
const searchParams = new URLSearchParams({
token_hash: l.data!.properties!.hashed_token,
next: "/hereicome"
})
const loginLink = `/auth/confirm?${searchParams}`;

Note that we also moved redirectTo from generateLink to our serverside verification URL, since we don’t use the generateLink’s provided link anyway.

References

Much of the information is sources from Github PRs and Supabase docs (especially from comments by @kangmingtay and @silentworks)