# How to add login and signup to a SvelteKit app

Login, signup, password reset, email verification, one-time codes and OAuth buttons, plus a settings page and a protected dashboard. vela enable auth generates all of it on PocketBase, with a server test for every route.

Pattern: [Enable authentication](https://velastack.dev/patterns/enable-auth) · Docs: https://docs.velastack.dev/enable/auth

Every app with users starts the same way: a login page, a signup page, "forgot your password", a verification email, somewhere to change your details, and a rule that keeps signed-out visitors away from the rest. Written by hand that is a week of forms, cookies and edge cases before the app does anything of its own. `vela enable auth` writes the whole set on top of PocketBase, as ordinary SvelteKit routes you can read and change.

## Prerequisites

A vela project with a backend: `npx vela create my-app`, or `npx vela bless` inside an existing SvelteKit project ([details](https://docs.velastack.dev/bless)). Verification and reset emails need an SMTP server in production; [`vela enable smtp`](https://docs.velastack.dev/enable/smtp) configures one.

## Run the command

```sh
$ vela enable auth
✔ Created 49 files, modified 4
✔ Added collection oauth_accounts
✔ Installed 16 UI components
  src/routes/(public)/(auth)/login/
  src/routes/(public)/(auth)/signup/
  src/routes/(public)/(auth)/reset/
  src/routes/(public)/(auth)/confirm-reset/[token]/
  src/routes/(public)/(auth)/otp/[token]/
  src/routes/(public)/(auth)/confirm-verification/[token]/
  src/routes/(public)/(auth)/confirm-email-change/[token]/
  src/routes/(public)/(auth)/logout/
  src/routes/(app)/dashboard/
  src/routes/(app)/settings/
  src/lib/schemas/
  src/hooks.server.ts
```

Each route comes with its page, its server code, a Zod schema for its form and a `server.test.ts`. Twenty-one tests in total.

## What was generated

### A protected area

One line in the server hook makes every route under `(app)` require a session. A signed-out visitor is sent to `/login` with a `redirect` parameter, and lands back where they were after signing in:

**src/hooks.server.ts**

```ts
import { env } from "$env/dynamic/private";
import { handlePocketbase } from "@velastack/pocketbase";

export const handle = handlePocketbase({
  pocketbaseUrl: env.POCKETBASE_URL,
  superuserEmail: env.POCKETBASE_SUPERUSER_EMAIL,
  superuserPassword: env.POCKETBASE_SUPERUSER_PASSWORD,
  // [!code highlight:1]
  auth: { protectedRoutes: ["/(app)"] },
});
```

The `(app)` group gets a layout with a sidebar, breadcrumbs and a user menu, a `/dashboard` to land on and a `/settings` page. Its server layout hands the signed-in user to every page beneath it:

**src/routes/(app)/+layout.server.ts**

```ts
export const load = ({ locals }) => {
  const user = locals.pb.authStore.record!;
  const breadcrumbs = [{ title: "Home", url: "/dashboard" }];

  return { user, breadcrumbs };
};
```

The root layout exposes the same record as `data.user`, so a public page can show "Sign in" or the user's name.

### Login

The login page asks PocketBase which sign-in methods are switched on and adapts. With one-time codes enabled the form defaults to emailing a code; otherwise it asks for a password; with only OAuth providers it shows just their buttons:

**src/routes/(public)/(auth)/login/+page.server.ts**

```ts
import { fail, superValidate, message } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { loginSchema } from "$lib/schemas/login";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/environment";

export const load = async ({ locals }) => {
  if (locals.pb.authStore.isValid) {
    redirect(303, "/dashboard");
  }

  const authMethods = await locals.admin.collection("users").listAuthMethods();

  const type: "password" | "otp" | "oauth2" = authMethods.otp.enabled
    ? "otp"
    : authMethods.password.enabled
      ? "password"
      : "oauth2";

  return {
    form: await superValidate(
      zod4(loginSchema.default({ type, email: "", password: "" })),
    ),
    authMethods,
  };
};

export const actions = {
  default: async ({ locals, request, cookies, url }) => {
    const form = await superValidate(request, zod4(loginSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

    const redirectParam = url.searchParams.get("redirect");
    if (form.data.type === "otp") {
      const req = await locals.pb
        .collection("users")
        .requestOTP(form.data.email);
      return redirect(
        303,
        `/otp/${req.otpId}${redirectParam ? `?redirect=${encodeURIComponent(redirectParam)}` : ""}`,
      );
    } else if (form.data.type === "password") {
      try {
        await locals.pb
          .collection("users")
          .authWithPassword(form.data.email, form.data.password);
      } catch (error: any) {
        return message(
          form,
          { type: "error", text: error.response.message },
          { status: 400 },
        );
      }
    }

    const redirectUrl = redirectParam ?? "/dashboard";
    const cookie = locals.pb.authStore.getCookie();
    cookies.set("pb_auth", cookie, {
      path: "/",
      httpOnly: true,
      sameSite: "lax",
      secure: !dev,
      maxAge: 60 * 60 * 24 * 30,
    });

    redirect(303, redirectUrl);
  },
};
```

The action handles both paths. A one-time code request redirects to `/otp/<id>` to enter the code; a password sets the session cookie and redirects:

**src/routes/(public)/(auth)/login/+page.server.ts**

```ts
import { fail, superValidate, message } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { loginSchema } from "$lib/schemas/login";
import { redirect } from "@sveltejs/kit";
import { dev } from "$app/environment";

export const load = async ({ locals }) => {
  if (locals.pb.authStore.isValid) {
    redirect(303, "/dashboard");
  }

  const authMethods = await locals.admin.collection("users").listAuthMethods();

  const type: "password" | "otp" | "oauth2" = authMethods.otp.enabled
    ? "otp"
    : authMethods.password.enabled
      ? "password"
      : "oauth2";

  return {
    form: await superValidate(
      zod4(loginSchema.default({ type, email: "", password: "" })),
    ),
    authMethods,
  };
};

export const actions = {
  default: async ({ locals, request, cookies, url }) => {
    const form = await superValidate(request, zod4(loginSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

    const redirectParam = url.searchParams.get("redirect");
    if (form.data.type === "otp") {
      const req = await locals.pb
        .collection("users")
        .requestOTP(form.data.email);
      return redirect(
        303,
        `/otp/${req.otpId}${redirectParam ? `?redirect=${encodeURIComponent(redirectParam)}` : ""}`,
      );
    } else if (form.data.type === "password") {
      try {
        await locals.pb
          .collection("users")
          .authWithPassword(form.data.email, form.data.password);
      } catch (error: any) {
        return message(
          form,
          { type: "error", text: error.response.message },
          { status: 400 },
        );
      }
    }

    const redirectUrl = redirectParam ?? "/dashboard";
    const cookie = locals.pb.authStore.getCookie();
    cookies.set("pb_auth", cookie, {
      path: "/",
      httpOnly: true,
      sameSite: "lax",
      secure: !dev,
      maxAge: 60 * 60 * 24 * 30,
    });

    redirect(303, redirectUrl);
  },
};
```

OAuth buttons are rendered for whatever providers you enable in the PocketBase admin. The click opens the provider's window and lands on the dashboard; the `oauth_accounts` collection keeps the provider tokens:

**src/routes/(public)/(auth)/login/+page.svelte**

```svelte
<script lang="ts">
  import favicon from "$lib/assets/favicon.svg";
  import { site } from "$lib/site";

  import { untrack } from "svelte";
  import { Button } from "$lib/components/ui/button";
  import * as Card from "$lib/components/ui/card";
  import { superForm } from "sveltekit-superforms";
  import { zod4Client } from "sveltekit-superforms/adapters";
  import { loginSchema } from "$lib/schemas/login";
  import * as Form from "$lib/components/ui/form";
  import { Input } from "$lib/components/ui/input";
  import PocketBase from "pocketbase-sveltekit";
  import { goto } from "$app/navigation";
  import { page } from "$app/state";

  let { data } = $props();
  let authMethods = $derived(data.authMethods);

  const redirect = page.url.searchParams.get("redirect");
  const hasOAuth2 = $derived(
    authMethods.oauth2.enabled && authMethods.oauth2.providers.length > 0,
  );
  const hasAuthMethods = $derived(
    authMethods.password.enabled || authMethods.otp.enabled || hasOAuth2,
  );

  const form = superForm(
    untrack(() => data.form),
    {
      validators: zod4Client(loginSchema),
    },
  );

  const handleOAuth2 = (provider: string) => {
    const pb = new PocketBase("/");

    pb.collection("users")
      .authWithOAuth2({ provider, createData: {} })
      .then(() => goto("/dashboard"));
  };

  const { form: formData, message } = form;
</script>

<div class="h-full flex flex-col items-center justify-center gap-6 p-6 md:p-10">
  <div class="flex w-full max-w-sm flex-col gap-6">
    <a href="/" class="flex items-center gap-2 self-center font-medium">
      <div
        class="bg-primary text-primary-foreground flex size-6 items-center justify-center rounded-md"
      >
        <img src={favicon} alt="logo" class="size-4" />
      </div>
      {site.name}
    </a>

    <div class="flex flex-col gap-6">
      <Card.Root>
        {#if hasAuthMethods}
          <Card.Header class="text-center">
            <Card.Title class="text-xl">Welcome back</Card.Title>
            {#if hasOAuth2}
              <Card.Description>Choose a login method</Card.Description>
            {:else}
              <Card.Description>Use your email to login</Card.Description>
            {/if}
          </Card.Header>
        {:else}
          <Card.Header class="text-center">
            <Card.Title class="text-xl">Login is disabled</Card.Title>
            <Card.Description
              >Check back later for login options</Card.Description
            >
          </Card.Header>
        {/if}
        <Card.Content>
          <form method="POST">
            <div class="grid gap-6">
              {#if hasOAuth2}
                <div class="flex flex-col gap-4">
                  {#each authMethods.oauth2.providers as provider}
                    <Button
                      variant="outline"
                      class="w-full"
                      onclick={() => handleOAuth2(provider.name)}
                    >
                      <img
                        src="/admin/_/images/oauth2/{provider.name}.svg"
                        class="size-5 bg-white p-0.5 rounded-sm"
                        alt=""
                      />
                      Login with {provider.displayName}
                    </Button>
                  {/each}
                </div>
              {/if}

              {#if authMethods.password.enabled || authMethods.otp.enabled}
                {#if hasOAuth2}
                  <div
                    class="after:border-border relative text-center text-sm after:absolute after:inset-0 after:top-1/2 after:z-0 after:flex after:items-center after:border-t"
                  >
                    <span
                      class="bg-card text-muted-foreground relative z-10 px-2"
                    >
                      Or continue with
                    </span>
                  </div>
                {/if}

                <div class="grid gap-2">
                  <Form.Field {form} name="email" class="col-span-1">
                    <Form.Control>
                      {#snippet children({ props })}
                        <Form.Label>Email</Form.Label>
                        <Input
                          {...props}
                          type="email"
                          bind:value={$formData.email}
                          required
                          autocomplete="username"
                          autofocus
                        />
                      {/snippet}
                    </Form.Control>
                    <Form.FieldErrors class="contents text-destructive" />
                  </Form.Field>

                  {#if $formData.type === "password" && authMethods.password.enabled}
                    <Form.Field {form} name="password" class="col-span-1">
                      <Form.Control>
                        {#snippet children({ props })}
                          <div class="grid grid-cols-[1fr_auto] gap-2">
                            <Form.Label>Password</Form.Label>
                            <Input
                              {...props}
                              type="password"
                              bind:value={$formData.password}
                              required
                              autocomplete="current-password"
                              class="col-span-2"
                            />
                            <a
                              href="/reset"
                              class="col-start-2 row-start-1 text-sm underline-offset-4 hover:underline"
                              >Forgot your password?</a
                            >
                          </div>
                        {/snippet}
                      </Form.Control>
                      <Form.FieldErrors class="contents text-destructive" />
                    </Form.Field>

                    {#if $message && $message.type === "error"}
                      <div class="text-destructive text-sm font-medium -mt-2">
                        {$message.text}
                      </div>
                    {/if}

                    <Button type="submit" class="w-full">Login</Button>
                    {#if authMethods.otp.enabled}
                      <Button
                        variant="outline"
                        class="w-full"
                        onclick={() => ($formData.type = "otp" as "password")}
                        >Use one-time code instead</Button
                      >
                    {/if}
                  {:else if $formData.type === "otp" && authMethods.otp.enabled}
                    <Button type="submit" class="w-full"
                      >Send one-time code</Button
                    >
                    {#if authMethods.password.enabled}
                      <Button
                        variant="outline"
                        class="w-full"
                        onclick={() => ($formData.type = "password" as "otp")}
                        >Continue with password</Button
                      >
                    {/if}
                  {/if}
                </div>

                <input type="hidden" name="type" bind:value={$formData.type} />
              {/if}

              <div class="text-center text-sm">
                Don&apos;t have an account?
                <a
                  href="/signup{redirect
                    ? `?redirect=${encodeURIComponent(redirect)}`
                    : ''}"
                  class="underline underline-offset-4">Sign up</a
                >
              </div>
            </div>
          </form>
        </Card.Content>
      </Card.Root>
      <div
        class="text-muted-foreground *:[a]:hover:text-primary *:[a]:underline *:[a]:underline-offset-4 text-balance text-center text-xs"
      >
        By clicking continue, you agree to our <a href="/terms"
          >Terms of Service</a
        >
        and <a href="/privacy">Privacy Policy</a>.
      </div>
    </div>
  </div>
</div>
```

### Signup

Signup creates the user, sends the verification email, signs them in and sets the cookie. A PocketBase validation error (an email already taken, say) comes back as a field error on the form:

**src/routes/(public)/(auth)/signup/+page.server.ts**

```ts
import { redirect } from "@sveltejs/kit";
import { fail, superValidate } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { setFlash } from "sveltekit-flash-message/server";
import { setPocketbaseErrors } from "@velastack/pocketbase/form";
import { dev } from "$app/environment";
import { signupSchema } from "$lib/schemas/signup";

export const load = async ({ locals }) => {
  const authMethods = await locals.admin.collection("users").listAuthMethods();

  if (locals.pb.authStore.isValid) {
    redirect(303, "/dashboard");
  }

  return { form: await superValidate(zod4(signupSchema)), authMethods };
};

export const actions = {
  default: async ({ locals, request, cookies, url }) => {
    const form = await superValidate(request, zod4(signupSchema));

    if (!form.valid) {
      return fail(400, { form });
    }

    let user;

    try {
      user = await locals.admin.collection("users").create({
        email: form.data.email,
        password: form.data.password,
        passwordConfirm: form.data.passwordConfirm,
      });
    } catch (error) {
      setPocketbaseErrors(form, error);
      return fail(400, { form });
    }

    await locals.pb.collection("users").requestVerification(user.email);
    await locals.pb
      .collection("users")
      .authWithPassword(form.data.email, form.data.password);

    const redirectUrl = url.searchParams.get("redirect") ?? "/dashboard";
    const cookie = locals.pb.authStore.getCookie();
    cookies.set("pb_auth", cookie, {
      path: "/",
      httpOnly: true,
      sameSite: "lax",
      secure: !dev,
      maxAge: 60 * 60 * 24 * 30,
    });

    setFlash(
      { type: "toast", message: "We sent a confirmation link to your email." },
      cookies,
    );
    return redirect(303, redirectUrl);
  },
};
```

### Email flows

Reset, verification and email change each have a page that requests the email and a `[token]` route the link in the email points at. Verification is a plain endpoint that confirms the token and redirects with a toast either way:

**src/routes/(public)/(auth)/confirm-verification/[token]/+server.ts**

```ts
import { redirect } from "@sveltejs/kit";
import { setFlash } from "sveltekit-flash-message/server";

export const GET = async ({ params, locals, cookies }) => {
  let message = "";
  let redirectPath = "";

  try {
    await locals.admin.collection("users").confirmVerification(params.token);
    message = "Email verified successfully.";
    redirectPath = "/dashboard";
  } catch (error: any) {
    message =
      "Invalid or expired verification token. Please request a new verification email.";
    redirectPath = "/login";
  }

  setFlash({ type: "toast", message }, cookies);
  redirect(303, redirectPath);
};
```

### Settings

`/settings` has four actions: update the profile (name, avatar, whether the email is visible), change the email, change the password, and resend the verification email:

**src/routes/(app)/settings/+page.server.ts**

```ts
import { superValidate, fail, withFiles } from "sveltekit-superforms";
import { zod4 } from "sveltekit-superforms/adapters";
import { setFlash } from "sveltekit-flash-message/server";
import { profileSchema } from "$lib/schemas/profile";
import { changeEmailSchema } from "$lib/schemas/changeEmail";
import { changePasswordSchema } from "$lib/schemas/changePassword";

export const load = async ({ parent }) => {
  const { user } = await parent();

  const profileForm = await superValidate(
    {
      avatar: user.avatar,
      name: user.name,
      emailVisibility: user.emailVisibility,
    },
    zod4(profileSchema),
  );
  const emailForm = await superValidate(zod4(changeEmailSchema));
  const passwordForm = await superValidate(zod4(changePasswordSchema));

  return { profileForm, emailForm, passwordForm, user };
};

export const actions = {
  updateProfile: async ({ locals, request, cookies }) => {
    const form = await superValidate(request, zod4(profileSchema));

    if (!form.valid) {
      return fail(400, { profileForm: form });
    }

    await locals.pb
      .collection("users")
      .update(locals.pb.authStore.record!.id, form.data);

    setFlash(
      { type: "toast", message: "Profile updated successfully." },
      cookies,
    );
    return withFiles({ profileForm: form });
  },
  changeEmail: async ({ locals, request, cookies }) => {
    const form = await superValidate(request, zod4(changeEmailSchema));

    if (!form.valid) {
      return fail(400, { emailForm: form });
    }

    await locals.pb.collection("users").requestEmailChange(form.data.email);
    setFlash(
      {
        type: "toast",
        message: "We sent a confirmation link to your new email.",
      },
      cookies,
    );
    return { emailForm: form };
  },
  changePassword: async ({ locals, request, cookies }) => {
    const form = await superValidate(request, zod4(changePasswordSchema));

    if (!form.valid) {
      return fail(400, { passwordForm: form });
    }

    await locals.admin
      .collection("users")
      .update(locals.pb.authStore.record!.id, {
        password: form.data.password,
        passwordConfirm: form.data.passwordConfirm,
      });

    setFlash(
      { type: "toast", message: "Password updated successfully." },
      cookies,
    );
    return { passwordForm: form };
  },
  resendVerificationEmail: async ({ locals, request, cookies }) => {
    await locals.pb
      .collection("users")
      .requestVerification(locals.pb.authStore.record!.email);
    setFlash(
      { type: "toast", message: "We sent a verification email to your email." },
      cookies,
    );
    return {};
  },
};
```

### Tests

Every route ships a server test. The login one signs in with a password, checks the redirect, and checks the message on a wrong password:

**src/routes/(public)/(auth)/login/server.test.ts**

```ts
import { describe, it, expect } from "vitest";
import * as devalue from "devalue";
import type { Match } from "@velastack/kit";
import type { RouteId } from "./$types";

describe("GET /login", () => {
  it("should render the login page", async (context) => {
    const response = await context.agent.get("/login" satisfies Match<RouteId>);
    expect(response.status).toBe(200);
  });

  it("should redirect to the dashboard if the user is already authenticated", async (context) => {
    await context.agent.authenticateUser();
    const response = await context.agent.get("/login" satisfies Match<RouteId>);
    expect(response.status).toBe(303);
    expect(response.headers.location).toBe("/dashboard");
  });
});

describe("POST /login", () => {
  it("should login with password", async (context) => {
    const response = await context.agent
      .post("/login" satisfies Match<RouteId>)
      .type("form")
      .send({
        type: "password",
        email: context.user.email,
        password: "password",
      });
    expect(response.body.status).toBe(303);
    expect(response.body.location).toBe("/dashboard");
  });

  it("should error if the password is incorrect", async (context) => {
    const response = await context.agent
      .post("/login" satisfies Match<RouteId>)
      .type("form")
      .send({
        type: "password",
        email: context.user.email,
        password: "incorrect",
      });
    const data = devalue.parse(response.body.data);
    expect(response.body.status).toBe(400);
    expect(data.form.message.text).toBe("Failed to authenticate.");
  });
});
```

## Try it

```sh
$ vela dev
```

Open [localhost:5173/signup](http://localhost:5173/signup), create an account and you land on `/dashboard`. Open `/settings` from the avatar menu, change your name, sign out, sign back in at `/login`. Visit `/dashboard` while signed out and watch the redirect.

## Use the user in your own code

Inside any `load` or action, `locals.pb` is a PocketBase client authenticated as the current user, so collection rules apply to every query. `locals.admin` bypasses them:

**src/routes/(app)/projects/+page.server.ts**

```ts
export const load = async ({ locals }) => {
  const user = locals.pb.authStore.record!;
  const projects = await locals.pb.collection("projects").getFullList();
  return { user, projects };
};
```

New routes belong under `(app)` if they need a session. The generators know this: with auth enabled, [`vela generate scaffold`](https://velastack.dev/tutorials/sveltekit-crud-scaffold) puts its routes there, and an `owner:current_user` field fills in from the session and restricts rows to their owner.

> **Tip:**
> To let people sign in without a password, enable one-time codes on the `users` collection in the PocketBase admin. The login page picks it up on the next request and offers "Send one-time code" first, with password as the fallback.

## Going further

- [The same flows on remote functions](https://velastack.dev/tutorials/sveltekit-login-remote-functions) with `vela enable auth --remote`
- [Add teams and invites](https://velastack.dev/tutorials/sveltekit-teams-and-invites) once users can sign in
- [Give users API keys](https://velastack.dev/tutorials/sveltekit-api-keys) for scripts and integrations
- [Pattern page: Authentication](https://velastack.dev/patterns/enable-auth) · [Docs: vela enable auth](https://docs.velastack.dev/enable/auth)
