a black and white photo of a njoguamos in a suit and with a serious look on his face

Authenticate Qwik App with Laravel Sanctum

Created 07 Feb 2024 | Updated 08 Feb 2024

LaravelQwik

In this article, I will demonstrate how to authenticate the Qwik web application using a Laravel-powered API backend. Before you proceed ensure that your Laravel API backend is ready for SPA authentication. I covered that in Preparing a Laravel API backend for SPA authentication using Sanctum. The following video show what we will be building.

What is Qwik

Qwik is a JavaScript framework that solves problems other frameworks can't solve. To truly appreciate Qwik's uniqueness, it's important to understand the shortcomings of current frameworks, which is hydration. With hydration, the frameworks must download all component code linked to the current page and execute associated templates to rebuild listener locations and internal components. Hydration is, therefore, expensive.

Qwik, on the other hand, uses no hydration and rather uses a technique known as Resumability. Qwik app serialises a page into HTML, pauses execution on the server, and the application can resume execution on the client without having to replay and re-download. Qwik apps have instant-on startup performance. They also have the same initial javascript regardless of the complexity of the application.

With Qwik, you can build applications that are instantly interactive, fast, and scalable - all with Developer Experience like that of React or Vue. You can learn more from the Qwik website.

Why not Just use Auth.js

Auth.js is an open-source authentication solution for web applications. Auth.js eliminated the complexity of authenticating with OAuth providers, databases, and email magic links, among others. While the library is great and has Qwik support, it has poor support for credentials authentication (username and password) and does not play well with Laravel.

My first attempt was to generate a Sanctum API token and store it in an Auth.js JSON Web Token (JWT) encrypted cookie. I also implemented session-based authentication using Auth.js. Both methods proved futile. User experience (UX), when credentials failed, could have been better; there was no fluent way of authenticating users after registration. I

I ended up going the vanilla way of authentication, and that is what I will cover in this article.

Install new qwik application

Install a new empty qwik application, if you do not have one already

npm create qwik@latest

Create vite environment variables

touch .env.local

Then add the following public build-time variables.

PUBLIC_API_ENDPOINT=http://localhost:8000
# PUBLIC_API_ENDPOINT=http://api.example.com -> production
PUBLIC_SESSION_NAME=laravel_api_session

Note: PUBLIC_API_ENDPOINT MUST match Laravel APP_URL while PUBLIC_SESSION_NAME MUST match Laravel SESSION_NAME. The variables are prefixed with PUBLIC_ so that we can access their value from the client side (browser).

Install modular form and valibot

Modular Forms is a type-safe Javascript form library that we will use to build our login form.

npm install -D @modular-forms/qwik

Next we add Validbot a type safe type safe data validation. We will use Validbot to minimise hits on out API endpoint

npm install -D valibot

Install Tailwindcss (optional)

Tailwind is a CSS framework that will help up add basic styles to our login page. Tailwindcss is optional and you can use any other framework.

npm run qwik add tailwind

Add login page

Create a new login route in your Qwik application by running the following command

npm run qwik new /login

Form here we are going to edit src/routes/login/index.tsx.Start by importing the api Laravel API endpoint to the client (browser).

const endpoint = import.meta.env.PUBLIC_API_ENDPOINT

Define the login form. The schema should include the error messages you wish to show your users.

import type { Input} from "valibot"
import {email, maxLength, minLength, object, string} from "valibot"
 
const LoginSchema = object({
	email: string([
		minLength(1, 'Please enter an email address.'),
		email('Enter a valid email e.g [email protected].'),
		maxLength(256, 'Email too long. Enter an shorter alternative email.'),
	]),
	password: string([minLength(1, 'Please enter a password.')]),
	// Add more fields
})
 
type LoginForm = Input<typeof LoginSchema>;

You can add more fields in the scheme if required. Next, set the initial value of the login form and link the validation.

import { useForm, valiForm$ } from '@modular-forms/qwik'
 
// ...
export default component$(() => {
    const [loginForm, { Form, Field }] =
        useForm <
        LoginForm >
        {
            loader: { value: { email: '', password: '' } },
            validate: valiForm$(LoginSchema),
        }
    // ...
})

Next, import useNavigation we will use it to re-direct the user once authenticated.

import { useNavigate } from '@builder.io/qwik-city'
 
export default component$(() => {
    // ...
    const nav = useNavigate()
    // ....
})

Add an empty form submit handler

import type { QRL} from "@builder.io/qwik"
import type {SubmitHandler} from "@modular-forms/qwik"
import {component$, $} from "@builder.io/qwik"
 
// ...
export default component$(() => {
	const handleSubmit: QRL<SubmitHandler<LoginForm>> = $(async (values) => {
		// TODO:
	})
})

Let us add the login form. Refer to modular form documentation if you need more details.

export default component$(() => {
    //....
    return (
        <section class="bg:white flex min-h-screen items-center justify-around md:bg-gray-200">
            <div class="grid w-full max-w-sm space-y-6 p-4 md:bg-white md:p-8">
                <h1 class="text-4xl font-medium">Login</h1>
                <Form onSubmit$={handleSubmit}>
                    <Field name="email">
                        {(field, props) => (
                            <div class="mb-5">
                                <label
                                    for="email"
                                    class="mb-2 block text-sm font-medium text-gray-900 dark:text-white"
                                >
                                    Email address
                                </label>
                                <input
                                    {...props}
                                    value={field.value}
                                    type="email"
                                    autoComplete="current-username"
                                    autoFocus
                                    id="email"
                                    class={[
                                        'block w-full rounded-lg border  bg-gray-50 p-3 text-sm text-gray-900 ',
                                        field.error
                                            ? 'border-red-600'
                                            : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500',
                                    ]}
                                />
                                {field.error && (
                                    <p class="mt-2 text-sm text-red-600 dark:text-red-500">{field.error}</p>
                                )}
                            </div>
                        )}
                    </Field>
 
                    <Field name="password">
                        {(field, props) => (
                            <div class="mb-5">
                                <label
                                    for="email"
                                    class="mb-2 block text-sm font-medium text-gray-900 dark:text-white"
                                >
                                    Password
                                </label>
                                <input
                                    {...props}
                                    value={field.value}
                                    type="password"
                                    autoComplete="current-password"
                                    id="password"
                                    class={[
                                        'block w-full rounded-lg border  bg-gray-50 p-3 text-sm text-gray-900 ',
                                        field.error
                                            ? 'border-red-600'
                                            : 'border-gray-300 focus:border-blue-500 focus:ring-blue-500',
                                    ]}
                                />
                                {field.error && (
                                    <p class="mt-2 text-sm text-red-600 dark:text-red-500">{field.error}</p>
                                )}
                            </div>
                        )}
                    </Field>
 
                    <button
                        class={[
                            'mb-2 me-2 rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium text-white',
                            'hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300',
                        ]}
                        disabled={loginForm.submitting}
                    >
                        {!loginForm.submitting && <span>Login</span>}
 
                        {loginForm.submitting && <span>Logging in ... </span>}
                    </button>
                </Form>
            </div>
        </section>
    )
})

You should now be having a styled form that can validate input using the rules we specified.

a screenshot of cloudflare sign up page

Add login functionality

When you submit the form the values are validated and passed to handleSubmit function we created. First We need to make a GET request to the ${endpoint}/sanctum/csrf-cookie endpoint to initialize CSRF protection for the application. Since we will user fetch in favour of axios, we enable { credentials: 'include' } to ensure the XSRF-TOKEN received from Laravel is saved in the browser cookies.

const handleSubmit: QRL<SubmitHandler<LoginForm>> = $(async (values) => {
  await fetch(`${endpoint}/sanctum/csrf-cookie`, { credentials: 'include' })
 
  const token = await getBrowserCookieValue('XSRF-TOKEN')
 
  // ....
});

We then read the value of XSRF-TOKEN from the browser using the helper function getBrowserCookieValue

const getBrowserCookieValue = $(function getBrowserCookieValue(cname: string) {
  const name = cname + '='
  const decodedCookie = decodeURIComponent(document.cookie)
  const ca = decodedCookie.split(';')
  for (let i = 0; i < ca.length; i++) {
    let c = ca[i]
    while (c.charAt(0) == ' ') {
      c = c.substring(1)
    }
    if (c.indexOf(name) == 0) {
      return c.substring(name.length, c.length)
    }
  }
 
  return ''
})

If the sanctum/csrf-cookie request was successful, token should now have a the value of XSRF-TOKEN similar to this example below

eyJpdiI6InhTS24xMkNKNENUdlRsT2JMK2QvUVE9PSIsI...TI4OTk3YzZmZGI2YmYxZTk4NjZlIiwidGFnIjoiIn0=

Next we make a POST request to ${endpoint}/login attaching the token as X-XSRF-TOKEN header. We also submit the values of email and password in the request.

const handleSubmit: QRL<SubmitHandler<LoginForm>> = $(async (values) => {
  await fetch(`${endpoint}/sanctum/csrf-cookie`, { credentials: 'include' })
 
  const token = await getBrowserCookieValue('XSRF-TOKEN')
 
  const response = await fetch(`${endpoint}/login`, {
	  method: "POST",
	  credentials: "include",
	  headers: {
	    "Content-Type": "application/json",
	    Accept: "application/json",
	    "X-XSRF-TOKEN": token,
	  },
	  body: JSON.stringify(values),
	});
 
  // ....
});

if the response is successful, we re-direct the user to the intended page, e.g dashboard.

import { setError } from "@modular-forms/qwik";
 
// ....
 
const handleSubmit: QRL<SubmitHandler<LoginForm>> = $(async (values) => {
 
  // ....
	if (response.ok) {
		// Change the page
	    nav('/dash', {
	      replaceState: true,
	      forceReload: true,
	    })
	  } else {
	    switch (response.status) {
	      case 422:
	        const json = await response.json()
	        const errors = json.errors
 
	        Object.keys(errors).forEach((key) => {
	          setError(loginForm, key as 'email' | 'password', errors[key])
	        })
	        break
	      default:
	        throw new Error('Sorry, there was an error when logging in. Refresh the page and try again.')
	    }
	  }
	});
});

If the response fails with 422, we display Laravel validation errors in the form. You can handle the rest of the errors as you deem fit.

a screenshot of cloudflare sign up page

Final login form

We get a functional login form if we put all the building blocks together.

import type { QRL } from "@builder.io/qwik";
import { component$, $ } from "@builder.io/qwik";
import type { Input } from "valibot";
import { email, maxLength, minLength, object, string } from "valibot";
import { type DocumentHead, useNavigate } from "@builder.io/qwik-city";
import type { SubmitHandler } from "@modular-forms/qwik";
import { setError } from "@modular-forms/qwik";
import { useForm, valiForm$ } from "@modular-forms/qwik";
 
// noinspection JSUnresolvedReference
const endpoint = import.meta.env.PUBLIC_API_ENDPOINT;
 
const LoginSchema = object({
  email: string([
    minLength(1, "Please enter an email address."),
    email("Enter a valid email e.g [email protected]."),
    maxLength(256, "Email too long. Enter an shorter alternative email."),
  ]),
  password: string([minLength(1, "Please enter a password.")]),
});
 
type LoginForm = Input<typeof LoginSchema>;
 
export default component$(() => {
  const [loginForm, { Form, Field }] = useForm<LoginForm>({
    loader: { value: { email: "", password: "" } },
    validate: valiForm$(LoginSchema),
  });
 
  const nav = useNavigate();
 
  const getBrowserCookieValue = $(function getBrowserCookieValue(
    cname: string,
  ) {
    const name = cname + "=";
    const decodedCookie = decodeURIComponent(document.cookie);
    const ca = decodedCookie.split(";");
    for (let i = 0; i < ca.length; i++) {
      let c = ca[i];
      while (c.charAt(0) == " ") {
        c = c.substring(1);
      }
      if (c.indexOf(name) == 0) {
        return c.substring(name.length, c.length);
      }
    }
 
    return "";
  });
 
  const handleSubmit: QRL<SubmitHandler<LoginForm>> = $(async (values) => {
    await fetch(`${endpoint}/sanctum/csrf-cookie`, { credentials: "include" });
 
    const token = await getBrowserCookieValue("XSRF-TOKEN");
 
    const response = await fetch(`${endpoint}/login`, {
      method: "POST",
      credentials: "include",
      headers: {
        "Content-Type": "application/json",
        Accept: "application/json",
        "X-XSRF-TOKEN": token,
      },
      body: JSON.stringify(values),
    });
 
    if (response.ok) {
      nav("/", {
        replaceState: true,
        forceReload: true,
      });
    } else {
      switch (response.status) {
        case 422:
          const json = await response.json();
          const errors = json.errors;
 
          Object.keys(errors).forEach((key) => {
            setError(loginForm, key as "email" | "password", errors[key]);
          });
          break;
        default:
          // noinspection ExceptionCaughtLocallyJS
          throw new Error(
            "Sorry, there was an error when logging in. Refresh the page and try again.",
          );
      }
    }
  });
 
  return (
    <section class="bg:white flex min-h-screen items-center justify-around md:bg-gray-100">
      <div class="grid w-full max-w-sm space-y-6 p-4 md:bg-white md:p-8">
        <h1 class="text-4xl font-medium">Login</h1>
        <Form onSubmit$={handleSubmit}>
          <Field name="email">
            {(field, props) => (
              // @ts-ignore
              <div class="mb-5">
                <label
                  for="email"
                  class="mb-2 block text-sm font-medium text-gray-900 dark:text-white"
                >
                  Email address
                </label>
                <input                  {...props}
                  value={field.value}
                  type="email"
                  autoComplete="current-username"
                  autoFocus
                  id="email"
                  class={[
                    "block w-full rounded-lg border  bg-gray-50 p-3 text-sm text-gray-900 ",
                    field.error
                      ? "border-red-600"
                      : "border-gray-300 focus:border-blue-500 focus:ring-blue-500",
                  ]}
                />
                {field.error && (
                  <p class="mt-2 text-sm text-red-600 dark:text-red-500">
                    {field.error}
                  </p>
                )}
              </div>
            )}
          </Field>
 
          <Field name="password">
            {(field, props) => (
              // @ts-ignore
              <div class="mb-5">
                <label
                  for="email"
                  class="mb-2 block text-sm font-medium text-gray-900 dark:text-white"
                >
                  Password
                </label>
                <input                  {...props}
                  value={field.value}
                  type="password"
                  autoComplete="current-password"
                  id="password"
                  class={[
                    "block w-full rounded-lg border  bg-gray-50 p-3 text-sm text-gray-900 ",
                    field.error
                      ? "border-red-600"
                      : "border-gray-300 focus:border-blue-500 focus:ring-blue-500",
                  ]}
                />
                {field.error && (
                  <p class="mt-2 text-sm text-red-600 dark:text-red-500">
                    {field.error}
                  </p>
                )}
              </div>
            )}
          </Field>
 
          <button            class={[
              "mb-2 me-2 rounded-lg bg-blue-700 px-5 py-2.5 text-sm font-medium text-white",
              "hover:bg-blue-800 focus:outline-none focus:ring-4 focus:ring-blue-300",
            ]}
            disabled={loginForm.submitting}
          >
            {!loginForm.submitting && <span>Login</span>}
 
            {loginForm.submitting && <span>Logging in ... </span>}
          </button>
        </Form>
      </div>
    </section>
  );
});
 
export const head: DocumentHead = {
  title: "Login",
  meta: [
    {
      name: "description",
      content: "Welcome back to my application",
    },
  ],
};

Adding authentication middleware

It is a common practice to secure some page such that they are only accessible to authenticated user. To implement this feature in Qwik, we need a middleware that check if the user is authenticated in every request. Create a new file at src/routes/[email protected] and add the following content.

import type { HeadersInit } from 'undici-types'
import type { RequestHandler } from '@builder.io/qwik-city'
import type { EnvGetter } from '@builder.io/qwik-city/middleware/request-handler'
 
type User = {
    name: string
    email: string
}
 
export const onRequest: RequestHandler = async ({ cookie, url, sharedMap, env }) => {
    const session_name = env.get('PUBLIC_SESSION_NAME') ?? ''
 
    const token = cookie.get('XSRF-TOKEN')
    const session = cookie.get(session_name)
 
    if (token && session) {
        const laravelCookie = `XSRF-TOKEN=${encodeURIComponent(token.value)};${session_name}=${encodeURIComponent(session.value)}`
 
        const headers: HeadersInit = {
            'X-XSRF-TOKEN': token.value,
            cookie: laravelCookie,
            origin: url.origin,
        }
 
        const user = await getAuthenticatedUser(headers, env)
 
        sharedMap.set('user', user)
    }}
 
async function getAuthenticatedUser(headers: HeadersInit, env: EnvGetter) {
    const endpoint = env.get('PUBLIC_API_ENDPOINT')
 
    return await fetch(`${endpoint}/api/user`, {
        method: 'GET',
        // @ts-ignore
        headers: {
            'Content-Type': 'application/json',
            Accept: 'application/json',
            ...headers,
        },
    })
        .then(async function (res) {
            if (res.status === 200) {
                return res.json()
            }        })
        .then((data) => data as User)
}

What is going on here?

  1. We read XSRF-TOKEN value from the cookie passed from the browser. We also read the cookie value for the laravel session that matches the session_name.
  2. If both cookie values are not undefined or null, the the user is like to be authenticated.
  3. We make a POST request to ${endpoint}/api/user to get the authenticated user. In the request, we attach XSRF-TOKEN as X-XSRF-TOKEN header, origin header and we attached laravel cookie. If we MUST include the three headers, else the request will return 401.
  4. If the response form Laravel is successful, we store the user in the sharedMap. Data stored in sharedMap is accessible in the HTTP request. You can learn more about shared map from Qwik documentation

Securing dashboard

Let us add a dashboard route that is only accessible in to authenticated user.

npm run qwik new /dashboard

We then add a middleware that check is there is a user in sharedMap. If not, we redirect the guest to login page.

import { component$ } from "@builder.io/qwik";
import { useUser } from "~/routes/layout";
 
import type { RequestHandler } from "@builder.io/qwik-city";
 
export const onRequest: RequestHandler = ({ redirect, sharedMap }) => {
  if (!sharedMap.get("user")) {
    throw redirect(302, "login");
  }
};
 
export default component$(() => {
  const user = useUser();
 
  return (
    <div class="min-h-screen w-full bg-gray-50">
      <aside class="hidden min-h-dvh w-48 bg-white shadow  md:fixed md:inset-y-0 md:left-0 md:flex">
        Sidebar
      </aside>
      <div class="flex min-h-screen w-full items-center justify-center p-10 md:ml-48">
        <div class="flex max-w-sm flex-col items-center space-y-2 bg-white p-6">
          <img
            src="https://i.pravatar.cc/300"
            height="300"
            width="300"
            alt="sample user"
            class="h-20 w-20 rounded-full"
          />
          <div class="h1 text-2xl">Welcome</div>
          <p class="text-lg">{user.value.name}</p>
          <p class="text-lg">{user.value.email}</p>
        </div>
      </div>
    </div>
  );
});

The useUser is a re-usable function placed in the src/routes/layout.tsx that get the user from sharedMap

import { component$, Slot } from "@builder.io/qwik";
import { routeLoader$ } from "@builder.io/qwik-city";
import type { User } from "~/routes/plugin@auth";
 
export const useUser = routeLoader$(({ sharedMap }) => {
  return sharedMap.get("user") as User;
});
 
export default component$(() => {
  return <Slot />;
});

Conclusion

By now you should be able to implement other authentication requirements such as logout and registration. The source code for both the Laravel API and Qwik app is available on Github

Common errors

  1. You are getting 401 unauthenticated error even after successful login. Solution: Ensure your laravel SANCTUM_STATEFUL_DOMAINS include you frontend e.g SANCTUM_STATEFUL_DOMAINS=localhost:5173

Reach out

Let’s talk about working together.

Available for new opportunities