Authentication in SvelteKit with Auth.js

Uri Seroussi
12 min readJul 13, 2023

--

[Updated 19 May 2024]

I was working on a SvelteKit app and needed to authenticate users. I’ve previously used NextAuth.js while working on Next.js projects. NextAuth recently expanded to a general authentication solution for more frameworks and rebranded into Auth.js. The SvelteKit Auth implementation is still experimental at the time of writing and not all the documentation was ported so perhaps a little guide can help others where I struggled [Update 19/5/2024: The docs are much better now!].

Our aims for this project:

  • Authenticate with Google OAuth
  • Authenticate with Magic Links
  • Persist Users in a database (MongoDB)
  • Create our own custom login screen

You can check out the repository and whole code example.

⚙️Tech: SvelteKit 2 | Auth.js | MongoDB | SendGrid

Let’s start by creating our project

npm create svelte@latest sveltekit-authjs-example

And installing Auth.js

npm install @auth/core @auth/sveltekit

Now we need to set up Auth.js. We have a few options to control user authentication flow:

  • OAuth

OAuth is a secure protocol that allows users to grant limited access to their resources on one website or application to another, without sharing passwords.

  • Magic Links

Magic Links are a passwordless authentication method where users receive a unique link via email. Clicking on the link verifies their identity and grants access without requiring traditional username and password input.

  • Credential authentication

Credential authentication is the traditional method where users provide a username and password.

We’ll cover the OAuth and Magic Links here.

Authentication With OAuth (Google)

Auth.js offers a pretty comprehensive list of popular 3rd party services it can support out of the box. Each one has a documented usage. Being a breeze to set up, let’s start off the example by using the Google Provider.

Create a file src/hooks.server.js where we will configure Auth.js

// src/hooks.server.js

import { SvelteKitAuth } from '@auth/sveltekit';
import GoogleProvider from '@auth/core/providers/google';
import {
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
} from '$env/static/private';

export const { handle } = SvelteKitAuth({
providers: [GoogleProvider({ clientId: GOOGLE_CLIENT_ID, clientSecret: GOOGLE_CLIENT_SECRET })]
});

And a .env file at the root of your project.

# Auth.js secret
AUTH_SECRET="your-atleast-32-characters-long-secret"

# Google client OAuth credentials
GOOGLE_CLIENT_ID="your-client-id"
GOOGLE_CLIENT_SECRET="your-client-secret"

💡Auth.js tip
Auth.js can automatically infer environment variables that are prefixed with
AUTH_, so you don’t have to plug them in to the provider in the config. For OAuth providers, follow this format: AUTH_{PROVIDER}_{ID|SECRET}. I prefer to be explicit.

You’ll need to get a Google client ID and a Google client secret. This is pretty easy to get from the Google Identity developer console. Create a new web project and copy the Google Client credentials to your .env file.

Steps to get a Google API client ID

You can manage the project on Google Cloud on the API console. Note that the project is in “testing” mode automatically and limited to 100 OAuth sign ins. When going to production set up the project accordingly and verify your app with Google. But before we can sign in, we need to define the domain from which the request will be made and our redirect URL. In the API console, go to the credentials menu and choose your project (by default named OAuth). There, set the Authorized JavaScript origins to http://localhost:5173 and the Authorized redirect URIs to http://localhost:5173/auth/callback/google.

Managing Google OAuth Client

We’ll also need another .env variable for Auth.js to work and that is the AUTH_SECRET. This is used to hash tokens, sign cookies and generate cryptographic keys. It should be a minimum of 32 characters long. You can get a randomly generated one here.

And that’s pretty much it! You’re all set up. Auth.js exposes default unbranded pages like the sign in (/auth/signin) and sign out (/auth/signout) pages. Now to test it out, we can go to http://localhost:5173/auth/signin and click on the sign in with Google button and you should see the all-so-familiar Google OAuth login screen.

Testing Authentication

Alright, we got some fancy looking login stuff going on, but does it actually do anything? Let’s test it out by creating a route only authenticated users can visit.

We’ll just change a bit the homepage src/routes/+page.svelte.

// src/routes/+page.svelte

<h1>Public</h1>
<a href='/protected'>protected route</a>

And add a protected route and page src/routes/protected/+page.svelte.

// src/routes/protected/+page.svelte

<h1>Protected</h1>
<a href='/'>public route</a>

Right now, anyone can visit this protected page. We want to protect all the pages under the /protected route. To do this, we can leverage SvelteKits layouts and check if there is a currently active user session. If there is no session, we’ll redirect the user to the sign in page.

Add src/routes/protected/+layout.server.js.

// src/routes/protected/+layout.server.js

import { redirect } from '@sveltejs/kit';

export const load = async (event) => {
const session = await event.locals.auth();

if (!session) {
redirect(307, 'auth/signin');
}

return {
session
};
};

That’s it! Test it out and see that you can only access the protected route after signing in.

The session object will hold information about the session and user. You can add more information to the session object as needed. Especially if you need additional information about the user if you are managing users in a database.

// session object from getSession()

{
user: {
name: string
email: string
image: string
},
expires: Date // This is the expiry of the session
}

Persisting Users in a Database

Usually, we’d like to persist user data and manage sessions remotely as an admin or add custom information to our users. For this we’ll need to persist users to a database. Auth.js has adapters for popular databases, making user database persistence a walk in the park. Let’s see an example with MongoDB. I’ll assume you have some familiarity with setting up a MongoDB database, and if not, no worries, the internet is full of tutorials. We’re just showing the gist of things here for Auth.js.

First we’ll need to install the adapter and mongodb driver

npm install @auth/mongodb-adapter mongodb

Auth.js will need some help to use the mongodb adapter. It will need to know how to connect to our database. For this, we can create a file that will export a promise of the MongoClient instance.

Add src/lib/database/clientPromise.js.

// src/lib/database/clientPromise.js

// This approach is taken from https://github.com/vercel/next.js/tree/canary/examples/with-mongodb
import { MongoClient } from 'mongodb';
import { NODE_ENV, MONGODB_CONNECTION_STRING } from '$env/static/private';

if (!MONGODB_CONNECTION_STRING) {
throw new Error('Invalid/Missing environment variable: "MONGODB_CONNECTION_STRING"');
}

const uri = MONGODB_CONNECTION_STRING;
const options = {};

let client;
let clientPromise;

if (NODE_ENV === 'development') {
// In development mode, use a global variable so that the value
// is preserved across module reloads caused by HMR (Hot Module Replacement).
if (!global._mongoClientPromise) {
client = new MongoClient(uri, options);
global._mongoClientPromise = client.connect();
}
clientPromise = global._mongoClientPromise;
} else {
// In production mode, it's best to not use a global variable.
client = new MongoClient(uri, options);
clientPromise = client.connect();
}

// Export a module-scoped MongoClient promise. By doing this in a
// separate module, the client can be shared across functions.
export default clientPromise;

And of course for this to work we’ll need to update our .env file with the required environment variables.

# environment
NODE_ENV="development"

# database
MONGODB_CONNECTION_STRING='your-mongodb-connection-string'

# Auth.js secret
AUTH_SECRET="your-atleast-32-characters-long-secret"

# Google client OAuth credentials
GOOGLE_CLIENT_ID="your-client-id"
GOOGLE_CLIENT_SECRET="your-client-secret"

Now let’s add the adapter in our Auth.js configuration

// src/hooks.server.js

import { SvelteKitAuth } from '@auth/sveltekit';
import { MongoDBAdapter } from '@auth/mongodb-adapter';
import GoogleProvider from '@auth/core/providers/google';
import { GOOGLE_CLIENT_ID, GOOGLE_CLIENT_SECRET, NODE_ENV } from '$env/static/private';
import clientPromise from '$lib/database/clientPromise';

export const { handle } = SvelteKitAuth({
providers: [GoogleProvider({ clientId: GOOGLE_CLIENT_ID, clientSecret: GOOGLE_CLIENT_SECRET })],
adapter: MongoDBAdapter(clientPromise, { databaseName: NODE_ENV })
});

And voila! It’s that simple. Now after you sign in with Google, you’ll see a sessions, accounts and users collections in the database.

Authentication With Magic Links

We have ourselves some OAuth authentication going and user data in our database. What if our users don’t have an OAuth login or they forgot their OAuth login credentials? Well, we can give those users an option to log in with just an email address!

Briefly, the way it works in Auth.js is that when signing in, an email with a Verification Token is sent. The token is valid for 24 hours by default. If used within that time, a new account is created and the user is signed in. If signing in with an already existing account’s email the user is signed in to that account.

For this to work, we must set up a database to store verification tokens. Luckily, we already setup a database in the section above. We also need a way to send emails. The Auth.js Nodemailer Provider uses nodemailer by default, we just need to configure it with an SMTP account.

SMTP stands for Simple Mail Transfer Protocol. It is a widely-used standard for sending and delivering email messages between servers. An SMTP account, also known as an SMTP server or SMTP service, refers to the configuration settings and credentials required to send emails using the SMTP protocol. It is an email server that handles outgoing mail. We’ll setup an account with SendGrid for this purpose.

So Let’s just add the Auth.js magic in the configuration file!

First, we need to install nodemailer

npm install nodemailer

Then we need to get our SMTP account credentials. You can use whichever you like, but you can quickly setup an account on SendGrid:

  • Create an account
  • Create an API key — Go to your dashboard -> settings -> API keys -> create API key
  • Verify single sender — Dashboard -> settings -> sender authentication -> verify single sender

Update the .env file with the SMTP information.

# environment
NODE_ENV="development"

# database
MONGODB_CONNECTION_STRING='your-mongodb-connection-string'

# Auth.js secret
AUTH_SECRET="your-atleast-32-characters-long-secret"

# Email
SMTP_USER=apikey
SMTP_PASSWORD="your-sendgrid-api-key"
SMTP_HOST=smtp.sendgrid.net
SMTP_PORT=587
EMAIL_FROM="your-verified-single-sender"

# Google client OAuth credentials
GOOGLE_CLIENT_ID="your-client-id"
GOOGLE_CLIENT_SECRET="your-client-secret"

Now we’re ready to configure Auth.js to use the Nodemailer Provider.

// src/hooks.server.js

import { SvelteKitAuth } from '@auth/sveltekit';
import { MongoDBAdapter } from '@auth/mongodb-adapter';
import GoogleProvider from '@auth/core/providers/google';
import {
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
NODE_ENV,
SMTP_HOST,
SMTP_PORT,
SMTP_USER,
SMTP_PASSWORD,
EMAIL_FROM
} from '$env/static/private';
import clientPromise from '$lib/database/clientPromise';
import NodemailerProvider from '@auth/core/providers/nodemailer';

export const { handle } = SvelteKitAuth({
providers: [
NodemailerProvider({
server: {
host: SMTP_HOST,
port: Number(SMTP_PORT),
auth: {
user: SMTP_USER,
pass: SMTP_PASSWORD
}
},
from: EMAIL_FROM
}),
GoogleProvider({ clientId: GOOGLE_CLIENT_ID, clientSecret: GOOGLE_CLIENT_SECRET })
],
adapter: MongoDBAdapter(clientPromise, { databaseName: NODE_ENV })
});

Now if we go to the default sign in screen, we’ll see an option of signing in with just an email. Test it out!

💡Auth.js tip — SendGrid Provider
There is now a built-in
SendGrid Provider which you can use to abstract further the logic we input manually about the SMTP credentials, but for the sake of this example, it’s nice to know what goes behind the scenes.

💡Auth.js tip — Magic Links and Account Linking
If a user initially signs in with Google, and then tries to sign in with just their email with a magic link — no problem. If they initially sign in with just their email with a magic link, and then try with Google — it won’t work by default! This is a security measure because we can’t know if the 3rd party (Google in this case) has validated the email associated with the OAuth account. A bad actor could then create an OAuth account using someone else’s email and then use that to log into their account in our app. If you want to implement account linking — i.e. linking between the magic links and OAuth, you can either use your own logic, or if you know the OAuth provider validates emails and you accept the risk that they are responsible, you can add a flag to the Auth.js config:
allowDangerousEmailAccountLinking and set it to true.

Creating a Custom Authentication Experience

At the time of writing, the Auth.js default authentication screens are pretty dull. Usually we would like to have a custom and branded experience to welcome users. So let’s go about making a nicer looking authentication experience.

In this case, we would also probably want to remove the default pages provided by Auth.js. So first, let’s direct the sign in and sign out pages to a custom route. In our Auth.js config, we can add a pages field and assign a route to them. I’m going to direct both the sign in and sign out to a single route /login. This will overwrite the default pages (try it out, you’ll get a 404 for /auth/signin).

import { SvelteKitAuth } from '@auth/sveltekit';
import { MongoDBAdapter } from '@auth/mongodb-adapter';
import GoogleProvider from '@auth/core/providers/google';
import {
GOOGLE_CLIENT_ID,
GOOGLE_CLIENT_SECRET,
NODE_ENV,
SMTP_HOST,
SMTP_PORT,
SMTP_USER,
SMTP_PASSWORD,
EMAIL_FROM
} from '$env/static/private';
import clientPromise from '$lib/database/clientPromise';
import NodemailerProvider from '@auth/core/providers/email';

export const { handle } = SvelteKitAuth({
providers: [
NodemailerProvider({
server: {
host: SMTP_HOST,
port: Number(SMTP_PORT),
auth: {
user: SMTP_USER,
pass: SMTP_PASSWORD
}
},
from: EMAIL_FROM
}),
GoogleProvider({ clientId: GOOGLE_CLIENT_ID, clientSecret: GOOGLE_CLIENT_SECRET })
],
adapter: MongoDBAdapter(clientPromise, { databaseName: NODE_ENV }),
pages: {
signIn: '/login',
signOut: '/login'
}
});

Now we need an actual route and page under /login. So let’s add that.

// src/routes/login/+page.svelte

<div>
My custom login page
</div>

Great! We’ll handle the sign in and sign out here. I’m thinking we should first conditionally render the sign in UI if we are not logged in, and the sign out UI if we are logged in. To do that, we need access to the session.

We already accessed the session, if you recall, when we handled redirects from protected routes. We can use similar code here to check if we are logged in when visiting the /login route. Let’s load some page data.

// src/routes/login/+page.server.js

export const load = async (event) => {
const session = await event.locals.auth();

return {
session
};
};

Making progress! Let’s add this conditional rendering to our page

// src/routes/login/+page.svelte

<script>
import { page } from '$app/stores';
</script>

<div>
{#if !$page.data.session}
<p>Not logged in!</p>
{/if}

{#if $page.data.session}
<p>Logged in!</p>
{/if}
</div>

We need a form to log in with the Magic Links — we’ll need an input for the email and a submit button. We’ll also add a button to do the Google OAuth sign in.

// src/routes/login/+page.svelte

<script>
import { page } from '$app/stores';

let email = '';

const handleEmailSignIn = () => {
// handle email provider sign in
}

const handleGoogleSignIn = () => {
// handle Google OAuth sign in
}

const handleSignOut = () => {
// handle sign out
}
</script>

<div class="container">

{#if !$page.data.session}

<form on:submit={handleEmailSignIn}>
<input label="Email" type="email" bind:value={email} />
<button>Continue</button>
</form>

<button on:click={handleGoogleSignIn}>
Continue with Google
</button>

{/if}

{#if $page.data.session}

<div>
<button on:click={handleSignOut}>Sign out</button>
</div>

{/if}
</div>

Looking good (code-wise, not so much on the UI side, but we’ll get to that). Now we just need to handle the sign in and sign out. Auth.js makes this super simple by exposing the signIn() and signOut() functions. Let’s fill in the blanks.

// src/routes/login/+page.svelte

<script>
import { signIn, signOut } from '@auth/sveltekit/client';
import { page } from '$app/stores';

let email = '';

const handleEmailSignIn = () => {
signIn('nodemailer', { email, callbackUrl: '/protected' });
};

const handleGoogleSignIn = () => {
signIn('google', { callbackUrl: '/protected' });
};

const handleSignOut = () => {
signOut();
}
</script>

<div>

{#if !$page.data.session}

<form on:submit={handleEmailSignIn}>
<input label="Email" type="email" bind:value={email} />
<button>Continue</button>
</form>

<button on:click={handleGoogleSignIn}>
Continue with Google
</button>
{/if}

{#if $page.data.session}

<div>
<button on:click={handleSignOut}>Sign out</button>
</div>

{/if}

</div>
  • The signIn() function accepts 3 parameters — providerId, options, and authorizationParams.
  • For OAuth, we call the signIn() function and pass in the name of the provider. In this case “google”, and we want to redirect the user to the /protected route after sign in: signIn(“google”, { callbackUrl: “/protected” }).
  • For Magic Links, we need to pass in the provider first — “nodemailer” and the information on where to send the Magic Link to. We can also set to which URL we want to go to from that magic link. So we call signIn(“nodemailer”, { email, callbackUrl: “/protected” })

And that’s it. You may notice that with the Magic Link, after sign in you are redirected to a default page exposed by Auth.js to tell you to check your email. I’ll leave it as an exercise for the reader to create a custom page for that route. Additionally, if you want to customize the sent Magic Link email, be sure to check out the documentation.

As for the signing out? It’s a simple call to signOut().

Now all that is left is to beautify! You can check out the final example repository for all the beautification stuff.

Final sign in and sign out cards

If you like what you read, be sure to follow and bestow upon me the gift of claps! Feel free to connect with me on Twitter or LinkedIn 🥰

--

--