Add AT Protocol OAuth to SvelteKit + Cloudflare Workers#
You are adding AT Protocol OAuth authentication to an existing SvelteKit project deployed on Cloudflare Workers. This uses server-side OAuth with @atcute/oauth-node-client, Cloudflare KV for session storage, and SvelteKit remote functions.
Prerequisites#
The project must already use:
- SvelteKit with
@sveltejs/adapter-cloudflare - A
wrangler.jsonc(orwrangler.toml) config
Step 0: Ask the user#
Before making any changes, ask the user these questions:
-
UI: Should I add a login UI?
foxui— Use@foxui/sociallogin modal (polished, recommended)basic— Simple login/logout page at/userroute (uses Tailwind if available)none— Backend only, no UI (you'll build your own)
-
Collections: What AT Protocol collections should your app write to? (e.g.
xyz.statusphere.status,app.bsky.feed.like). Leave empty for read-only. -
Blobs: Does the app need to upload blobs (images, video)? If yes, what types? (e.g.
image/*,video/*)
Use the answers to customize settings.ts (marked with CUSTOMIZE below) and choose which UI dependencies/files to create.
Step 1: Install dependencies#
Always install:
pnpm add valibot
pnpm add -D @atcute/oauth-node-client @atcute/identity-resolver @atcute/lexicons @atcute/client @atcute/tid @cloudflare/workers-types tsx @atcute/atproto @atcute/bluesky
If UI choice is foxui:
pnpm add @foxui/social @foxui/core
Step 2: Create files#
Create all of the following files. These go into src/lib/atproto/ and src/routes/(oauth)/.
src/lib/atproto/settings.ts#
Fill in collections and blobs from the user's answers. If no collections were specified, use an empty array.
import { dev } from '$app/environment';
type Permissions = {
collections: readonly string[];
rpc: Record<string, string | string[]>;
blobs: readonly string[];
};
export const permissions = {
// CUSTOMIZE: add the user's collections
collections: [],
// CUSTOMIZE: add any authenticated RPC requests needed
rpc: {},
// CUSTOMIZE: add blob types if the user needs uploads (e.g. ['image/*'])
blobs: []
} as const satisfies Permissions;
type ExtractCollectionBase<T extends string> = T extends `${infer Base}?${string}` ? Base : T;
export type AllowedCollection = ExtractCollectionBase<(typeof permissions.collections)[number]>;
// PDS to use for signup (change to preferred PDS)
const devPDS = 'https://bsky.social/';
const prodPDS = 'https://bsky.social/';
export const signUpPDS = dev ? devPDS : prodPDS;
export const REDIRECT_PATH = '/oauth/callback';
export const DOH_RESOLVER = 'https://mozilla.cloudflare-dns.com/dns-query';
src/lib/atproto/metadata.ts#
src/lib/atproto/auth.svelte.ts#
src/lib/atproto/methods.ts#
src/lib/atproto/index.ts#
src/lib/atproto/server/signed-cookie.ts#
src/lib/atproto/server/kv-store.ts#
src/lib/atproto/server/oauth.ts#
src/lib/atproto/server/oauth.remote.ts#
src/lib/atproto/server/repo.remote.ts#
src/lib/atproto/server/session.ts#
src/lib/atproto/server/profile.ts#
src/lib/atproto/scripts/generate-key.ts#
src/lib/atproto/scripts/generate-secret.ts#
src/lib/atproto/scripts/setup-dev.ts#
src/routes/(oauth)/oauth/callback/+server.ts#
src/routes/(oauth)/oauth/jwks.json/+server.ts#
src/routes/(oauth)/oauth-client-metadata.json/+server.ts#
.env.example#
Step 3: Modify existing files#
src/app.d.ts#
Add these to the existing App namespace. Merge with any existing Locals or Platform fields — do not remove existing fields.
import type { OAuthSession } from '@atcute/oauth-node-client';
import type { Client } from '@atcute/client';
import type { Did } from '@atcute/lexicons';
Add to App.Locals:
session: OAuthSession | null;
client: Client | null;
did: Did | null;
Add to App.Platform:
env: {
OAUTH_SESSIONS: KVNamespace;
OAUTH_STATES: KVNamespace;
CLIENT_ASSERTION_KEY: string;
COOKIE_SECRET: string;
OAUTH_PUBLIC_URL: string;
PROFILE_CACHE?: KVNamespace;
};
Add at the bottom of the file (for lexicon type augmentation):
import type {} from '@atcute/atproto';
import type {} from '@atcute/bluesky';
src/hooks.server.ts#
Add session restoration. If the file already has a handle export, wrap both in sequence() from @sveltejs/kit.
import type { Handle } from '@sveltejs/kit';
import { restoreSession } from '$lib/atproto/server/session';
const atprotoHandle: Handle = async ({ event, resolve }) => {
const { session, client, did } = await restoreSession(
event.cookies, event.platform?.env
);
event.locals.session = session;
event.locals.client = client;
event.locals.did = did;
return resolve(event);
};
If no existing hooks: export const handle = atprotoHandle;
If existing hooks: export const handle = sequence(existingHandle, atprotoHandle); (import sequence from @sveltejs/kit)
src/routes/+layout.server.ts#
Add profile loading. Merge with any existing load function.
import type { LayoutServerLoad } from './$types';
import { loadProfile } from '$lib/atproto/server/profile';
export const load: LayoutServerLoad = async ({ locals, platform }) => {
if (!locals.did) return { did: null, profile: null };
const profile = await loadProfile(locals.did, platform?.env?.PROFILE_CACHE);
return { did: locals.did, profile };
};
If a load function already exists, merge the profile data into its return value.
src/routes/+layout.svelte (foxui only)#
Only if the user chose foxui. Add the login modal to the existing layout:
<script lang="ts">
import { AtprotoLoginModal } from '@foxui/social';
import { login, signup } from '$lib/atproto';
</script>
<!-- keep existing layout content, add this at the bottom: -->
<AtprotoLoginModal
login={async (handle) => {
await login(handle);
return true;
}}
signup={async () => {
signup();
return true;
}}
/>
To show the modal from anywhere, use @foxui/social state and @foxui/core components:
<script lang="ts">
import { Button } from '@foxui/core';
import { atProtoLoginModalState } from '@foxui/social';
import { user, logout } from '$lib/atproto';
</script>
{#if user.isLoggedIn}
<p>Signed in as {user.profile?.handle ?? user.did}</p>
<Button onclick={() => logout()}>Sign Out</Button>
{:else}
<Button onclick={() => atProtoLoginModalState.show()}>Sign In</Button>
{/if}
@foxui/core also exports Avatar, Input, and other UI primitives you can use.
src/routes/user/+page.svelte (basic only)#
Only if the user chose basic. Create this file:
<script lang="ts">
import { user, login, logout } from '$lib/atproto';
let handle = $state('');
let error = $state('');
let loading = $state(false);
async function handleLogin() {
if (!handle.trim()) return;
loading = true;
error = '';
try {
await login(handle);
} catch (e) {
error = e instanceof Error ? e.message : 'Login failed';
loading = false;
}
}
</script>
<div class="mx-auto max-w-sm p-8">
{#if user.isLoggedIn}
<p class="mb-4">Signed in as <strong>{user.profile?.handle ?? user.did}</strong></p>
<button
class="rounded bg-red-600 px-4 py-2 text-white hover:bg-red-700"
onclick={() => logout()}
>
Sign Out
</button>
{:else}
<h1 class="mb-4 text-xl font-bold">Sign in</h1>
<form onsubmit={handleLogin} class="flex flex-col gap-3">
<input
type="text"
bind:value={handle}
placeholder="handle.bsky.social"
class="rounded border px-3 py-2"
disabled={loading}
/>
{#if error}
<p class="text-sm text-red-600">{error}</p>
{/if}
<button
type="submit"
class="rounded bg-blue-600 px-4 py-2 text-white hover:bg-blue-700 disabled:opacity-50"
disabled={loading || !handle.trim()}
>
{loading ? 'Signing in...' : 'Sign In'}
</button>
</form>
{/if}
</div>
If the project does not use Tailwind, replace the Tailwind classes with plain inline styles.
svelte.config.js#
Add remoteFunctions: true inside kit.experimental:
kit: {
adapter: adapter(),
experimental: {
remoteFunctions: true
}
}
If experimental already exists, merge into it. Do not remove other experimental flags.
vite.config.ts#
Add dev server config for loopback OAuth:
server: {
host: '127.0.0.1',
port: 5183
}
Add this inside defineConfig(). Do not remove existing plugins or config.
wrangler.jsonc#
Add or merge these fields:
- Add
"nodejs_compat_v2"tocompatibility_flags(create the array if it doesn't exist) - Add
"OAUTH_PUBLIC_URL": "https://your-domain.com"tovars(createvarsif needed) - Add KV namespace placeholders to
kv_namespaces:
{ "binding": "OAUTH_SESSIONS", "id": "TODO" },
{ "binding": "OAUTH_STATES", "id": "TODO" }
Do not remove existing bindings or vars.
tsconfig.json#
Add "@cloudflare/workers-types" to compilerOptions.types. Create the types array if it doesn't exist.
package.json#
Add these to the scripts section:
"env:generate-key": "npx tsx src/lib/atproto/scripts/generate-key.ts",
"env:generate-secret": "npx tsx src/lib/atproto/scripts/generate-secret.ts",
"env:setup-dev": "npx tsx src/lib/atproto/scripts/setup-dev.ts"
.gitignore#
Ensure these lines are present:
.env
.env.*
!.env.example
Step 4: Run setup and verify#
- Run
pnpm env:setup-devto generate secrets in.env - Run
pnpm devto start the dev server - Verify it starts on
http://127.0.0.1:5183 - Tell the user:
- Dev mode uses a loopback client (no keys needed)
- For production: create KV namespaces with
npx wrangler kv namespace create OAUTH_SESSIONSandOAUTH_STATES, update the IDs inwrangler.jsonc, setOAUTH_PUBLIC_URLto their domain, and runnpx wrangler secret put CLIENT_ASSERTION_KEY/COOKIE_SECRETwith values frompnpm env:generate-key/pnpm env:generate-secret
Usage examples#
Login / Logout#
<script lang="ts">
import { user, login, logout } from '$lib/atproto';
</script>
{#if user.isLoggedIn}
<p>Signed in as {user.did}</p>
<button onclick={() => logout()}>Sign Out</button>
{:else}
<button onclick={() => login('user.bsky.social')}>Sign In</button>
{/if}
Write operations#
import { putRecord, deleteRecord, uploadBlob, createTID } from '$lib/atproto';
await putRecord({
collection: 'your.collection.name',
rkey: createTID(),
record: { text: 'hello', createdAt: new Date().toISOString() }
});
await deleteRecord({ collection: 'your.collection.name', rkey: 'some-key' });
const blob = await uploadBlob({ blob: file });
Read operations (no auth needed)#
import { listRecords, getRecord, getDetailedProfile } from '$lib/atproto';
const records = await listRecords({ did: 'did:plc:...', collection: 'your.collection.name' });
const profile = await getDetailedProfile({ did: 'did:plc:...' });