Last checked with Wasp 0.23.
This guide depends on external libraries or services, so it may become outdated over time. We do our best to keep it up to date, but make sure to check their documentation for any changes.Custom OAuth Provider
This guide shows you how to implement a custom OAuth provider in your Wasp application. We'll use Spotify as an example, but the same approach works for any OAuth provider.
Prerequisites
- A Wasp project with authentication set up
- An OAuth application registered with your provider (e.g., Spotify Developer Dashboard)
Setting up a Custom OAuth Provider
1. Configure main.wasp
Set up the auth configuration and API routes:
app SpotifyOauth {
wasp: {
version: "^0.21.0"
},
title: "spotify-oauth",
auth: {
userEntity: User,
onAuthFailedRedirectTo: "/",
methods: {
// Enable at least one OAuth provider so Wasp exposes OAuth helpers
google: {}
}
},
}
route RootRoute { path: "/", to: MainPage }
page MainPage {
component: import { MainPage } from "@src/MainPage",
}
api authWithSpotify {
httpRoute: (GET, "/auth/spotify"),
fn: import { authWithSpotify } from "@src/auth",
entities: []
}
api authWithSpotifyCallback {
httpRoute: (GET, "/auth/spotify/callback"),
fn: import { authWithSpotifyCallback } from "@src/auth",
entities: []
}
The route names are arbitrary, but the path on authWithSpotifyCallback must match the redirect URI you register with your provider. Spotify rejects localhost over HTTP, so register http://127.0.0.1:3001/auth/spotify/callback in the Spotify Developer Dashboard and set Wasp's URLs to match in step 2.
2. Configure environment variables
Create or update your .env.server file:
SPOTIFY_CLIENT_ID=your_client_id
SPOTIFY_CLIENT_SECRET=your_client_secret
# You may need dummy values for built-in providers if you're using them
# just to get the arctic package installed
GOOGLE_CLIENT_ID=x
GOOGLE_CLIENT_SECRET=x
# Spotify rejects `localhost`, so point Wasp at 127.0.0.1 instead
WASP_SERVER_URL=http://127.0.0.1:3001
WASP_WEB_CLIENT_URL=http://127.0.0.1:3000
REACT_APP_API_URL=http://127.0.0.1:3001
Most OAuth providers accept localhost directly; the 127.0.0.1 setup above is specific to Spotify. Other providers (e.g., Slack) require a real hostname. See Local Network Testing for the nip.io workaround.
3. Set up the database schema
Update your Prisma schema to store the user data you need:
model User {
id String @id @default(cuid())
name String
profilePicture String
}
4. Implement the OAuth handlers
The implementation splits across two files: pure Spotify logic in src/spotify.ts, and Wasp auth logic in src/auth.ts.
src/spotify.ts holds the Arctic client (the library Wasp uses for OAuth) and the Spotify /me profile fetch:
- JavaScript
- TypeScript
import * as arctic from "arctic";
import { config } from "wasp/server";
import * as z from "zod";
if (!process.env.SPOTIFY_CLIENT_ID || !process.env.SPOTIFY_CLIENT_SECRET) {
throw new Error(
"Please provide SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET in .env.server file",
);
}
const clientId = process.env.SPOTIFY_CLIENT_ID;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
const redirectURI = `${config.serverUrl}/auth/spotify/callback`;
export const spotify = new arctic.Spotify(clientId, clientSecret, redirectURI);
// Spotify user schema for validation
const spotifyUserSchema = z.object({
id: z.string(),
display_name: z.string(),
external_urls: z.object({
spotify: z.string(),
}),
images: z.array(
z.object({
url: z.string(),
height: z.number(),
width: z.number(),
}),
),
});
export async function getSpotifyUser(accessToken) {
const response = await fetch("https://api.spotify.com/v1/me", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return spotifyUserSchema.parse(await response.json());
}
import * as arctic from "arctic";
import { config } from "wasp/server";
import * as z from "zod";
if (!process.env.SPOTIFY_CLIENT_ID || !process.env.SPOTIFY_CLIENT_SECRET) {
throw new Error(
"Please provide SPOTIFY_CLIENT_ID and SPOTIFY_CLIENT_SECRET in .env.server file",
);
}
const clientId = process.env.SPOTIFY_CLIENT_ID;
const clientSecret = process.env.SPOTIFY_CLIENT_SECRET;
const redirectURI = `${config.serverUrl}/auth/spotify/callback`;
export const spotify = new arctic.Spotify(clientId, clientSecret, redirectURI);
// Spotify user schema for validation
const spotifyUserSchema = z.object({
id: z.string(),
display_name: z.string(),
external_urls: z.object({
spotify: z.string(),
}),
images: z.array(
z.object({
url: z.string(),
height: z.number(),
width: z.number(),
}),
),
});
export type SpotifyUser = z.infer<typeof spotifyUserSchema>;
export async function getSpotifyUser(
accessToken: string,
): Promise<SpotifyUser> {
const response = await fetch("https://api.spotify.com/v1/me", {
headers: {
Authorization: `Bearer ${accessToken}`,
},
});
return spotifyUserSchema.parse(await response.json());
}
src/auth.ts wires the route handlers and uses wasp/server/auth helpers (findAuthIdentity, createUser) to connect the OAuth identity to a Wasp session (see Custom Auth Actions for details). This is similar to what Wasp does internally for Google, GitHub and other supported providers:
- JavaScript
- TypeScript
import * as arctic from "arctic";
import {
createUser,
findAuthIdentity,
getRedirectUriForOneTimeCode,
tokenStore,
} from "wasp/server/auth";
import { spotify, getSpotifyUser } from "./spotify";
// Handler for /auth/spotify - initiates OAuth flow
export const authWithSpotify = async (req, res) => {
const state = arctic.generateState();
const url = await spotify.createAuthorizationURL(state, {
scopes: ["user-read-email"],
});
res.redirect(url.toString());
};
// Handler for /auth/spotify/callback - processes OAuth callback
export const authWithSpotifyCallback = async (req, res) => {
const code = req.query.code;
const tokens = await spotify.validateAuthorizationCode(code);
const spotifyUser = await getSpotifyUser(tokens.accessToken);
const providerId = {
providerName: "spotify",
providerUserId: spotifyUser.id,
};
const existingIdentity = await findAuthIdentity(providerId);
const authId = existingIdentity
? existingIdentity.authId
: await createUserFromSpotifyProfile(providerId, spotifyUser);
const oneTimeCode = await tokenStore.createToken(authId);
return res.redirect(getRedirectUriForOneTimeCode(oneTimeCode).toString());
};
async function createUserFromSpotifyProfile(providerId, spotifyUser) {
const userData = {
name: spotifyUser.display_name,
profilePicture:
spotifyUser.images[1]?.url ?? spotifyUser.images[0]?.url ?? "",
};
const user = await createUser(
providerId,
JSON.stringify(spotifyUser),
userData,
);
return user.auth.id;
}
import * as arctic from "arctic";
import type { AuthWithSpotify, AuthWithSpotifyCallback } from "wasp/server/api";
import {
createUser,
findAuthIdentity,
getRedirectUriForOneTimeCode,
tokenStore,
} from "wasp/server/auth";
import type { ProviderName } from "wasp/server/auth";
import { spotify, getSpotifyUser, type SpotifyUser } from "./spotify";
// Handler for /auth/spotify - initiates OAuth flow
export const authWithSpotify: AuthWithSpotify = async (req, res) => {
const state = arctic.generateState();
const url = await spotify.createAuthorizationURL(state, {
scopes: ["user-read-email"],
});
res.redirect(url.toString());
};
// Handler for /auth/spotify/callback - processes OAuth callback
export const authWithSpotifyCallback: AuthWithSpotifyCallback = async (
req,
res,
) => {
const code = req.query.code as string;
const tokens = await spotify.validateAuthorizationCode(code);
const spotifyUser = await getSpotifyUser(tokens.accessToken);
const providerId = {
providerName: "spotify" as ProviderName,
providerUserId: spotifyUser.id,
};
const existingIdentity = await findAuthIdentity(providerId);
const authId = existingIdentity
? existingIdentity.authId
: await createUserFromSpotifyProfile(providerId, spotifyUser);
const oneTimeCode = await tokenStore.createToken(authId);
return res.redirect(getRedirectUriForOneTimeCode(oneTimeCode).toString());
};
async function createUserFromSpotifyProfile(
providerId: { providerName: ProviderName; providerUserId: string },
spotifyUser: SpotifyUser,
): Promise<string> {
const userData = {
name: spotifyUser.display_name,
profilePicture:
spotifyUser.images[1]?.url ?? spotifyUser.images[0]?.url ?? "",
};
const user = await createUser(
providerId,
JSON.stringify(spotifyUser),
userData,
);
return user.auth!.id;
}
The tokenStore and getRedirectUriForOneTimeCode are internal Wasp APIs that may change in future versions. This guide relies on them because there is currently no public API for implementing fully custom OAuth flows.
5. Create the login page
Add a login button that redirects to your OAuth endpoint. This follows the same pattern as Wasp's custom social auth UI:
- JavaScript
- TypeScript
import { logout, useAuth } from "wasp/client/auth";
import { config } from "wasp/client";
export const MainPage = () => {
const { data: user } = useAuth();
return (
<div className="container">
<main>
{user ? (
<div>
<img src={user.profilePicture} alt="profile" />
<br />
Logged in as {user.name}
<br />
<button onClick={logout}>Log out</button>
</div>
) : (
<p>Not logged in</p>
)}
<div className="buttons">
<a
className="button button-filled"
href={`${config.apiUrl}/auth/spotify`}
>
Login with Spotify
</a>
</div>
</main>
</div>
);
};
import { logout, useAuth } from "wasp/client/auth";
import { config } from "wasp/client";
export const MainPage = () => {
const { data: user } = useAuth();
return (
<div className="container">
<main>
{user ? (
<div>
<img src={user.profilePicture} alt="profile" />
<br />
Logged in as {user.name}
<br />
<button onClick={logout}>Log out</button>
</div>
) : (
<p>Not logged in</p>
)}
<div className="buttons">
<a
className="button button-filled"
href={`${config.apiUrl}/auth/spotify`}
>
Login with Spotify
</a>
</div>
</main>
</div>
);
};
Using a Different OAuth Provider
Arctic (v1) supports many providers. Check its documentation for the full list and their specific setup requirements.
Each provider follows the same pattern. For example, to use Twitch instead of Spotify:
- Swap the Arctic provider:
new arctic.Twitch(clientId, clientSecret, redirectURI). - Fetch the user from
https://api.twitch.tv/helix/userswith the appropriate scopes (e.g.,user:read:email). - Update the
Userschema and the fields passed tocreateUserto match the provider's response.