Auth
hatk handles AT Protocol OAuth entirely server-side. When a user signs in, hatk runs the OAuth flow with their PDS (Personal Data Server), then stores the session in an encrypted cookie. Your frontend just calls login(handle) and logout() -- no token management, no client-side OAuth libraries.
How it works
- User enters their handle, frontend calls
login(handle) - Browser redirects to the user's PDS for authorization
- PDS redirects back to your server, which completes the token exchange
- Server sets an encrypted session cookie
- On subsequent requests,
parseViewer(cookies)reads the cookie to identify the user
Configuration
Enable OAuth in hatk.config.ts by adding an oauth section:
// hatk.config.ts
import { defineConfig } from "@hatk/hatk/config";
export default defineConfig({
// ... other config
oauth: {
issuer: "https://my-app.example.com",
scopes: ["atproto"],
clients: [
{
client_id: "https://my-app.example.com/oauth-client-metadata.json",
client_name: "my-hatk-app",
scope: "atproto",
redirect_uris: ["https://my-app.example.com/oauth/callback"],
},
// Local development client
{
client_id: "http://127.0.0.1:3000/oauth-client-metadata.json",
client_name: "my-hatk-app",
scope: "atproto",
redirect_uris: ["http://127.0.0.1:3000/oauth/callback"],
},
],
},
});OAuth config options
| Field | Description |
|---|---|
issuer | Your app's public URL. Used for OAuth metadata discovery. Optional in dev. |
scopes | Array of OAuth scopes your app needs |
clients | Array of OAuth client configurations (one per environment) |
Scopes
Scopes control what the token can do:
atproto-- base AT Protocol access (read-only)repo:<collection>?action=create&action=delete-- write access to a specific collection
For example, an app that creates and deletes status records:
scopes: ["atproto repo:xyz.statusphere.status?action=create&action=delete"],Frontend auth
login and logout are generated helpers available from $hatk/client. They handle the full OAuth redirect flow.
Login
login(handle) redirects the browser to the user's PDS for authorization. After the user approves, they're redirected back to your app with an active session:
import { login } from "$hatk/client";
await login("alice.bsky.social");
// Browser redirects to PDS → user approves → redirects back with session cookieLogout
logout() clears the session cookie:
import { logout } from "$hatk/client";
await logout();Login form example
A minimal Svelte login form using login and logout:
<script lang="ts">
import { login, logout } from '$hatk/client'
import { invalidateAll } from '$app/navigation'
let { data } = $props()
let handle = $state('')
let loading = $state(false)
let error = $state('')
async function handleLogin() {
if (!handle.trim()) return
loading = true
error = ''
try {
await login(handle)
} catch (e: any) {
error = e.message
} finally {
loading = false
}
}
async function handleLogout() {
await logout()
await invalidateAll()
}
</script>
{#if data.viewer}
<p>Signed in as <code>{data.viewer.did}</code></p>
<button onclick={handleLogout}>Sign out</button>
{:else}
<form onsubmit={handleLogin}>
<input type="text" bind:value={handle} placeholder="your.handle" />
<button type="submit" disabled={loading}>
{loading ? 'Signing in...' : 'Sign in'}
</button>
</form>
{#if error}
<p style="color: red;">{error}</p>
{/if}
{/if}Native app clients
hatk supports native app OAuth clients (iOS, Android, etc.) that use a custom URL scheme for redirects and communicate directly with the PAR and token endpoints.
Client configuration
Register a native client in hatk.config.ts using a custom scheme client_id and redirect_uri:
clients: [
// Native iOS app
{
client_id: "my-app://app",
client_name: "my-app-native",
scope: "atproto repo:xyz.statusphere.status?action=create",
redirect_uris: ["my-app://oauth/callback"],
},
],Account creation
Native clients can trigger account creation by sending prompt=create in the PAR request. When prompt=create is set, the login_hint parameter is treated as a PDS hostname (not a handle or DID), since the user doesn't have an account yet.
POST /oauth/par
Content-Type: application/x-www-form-urlencoded
client_id=my-app://app
&redirect_uri=my-app://oauth/callback
&response_type=code
&code_challenge=<challenge>
&code_challenge_method=S256
&scope=atproto
&prompt=create
&login_hint=selfhosted.socialThe login_hint should be the bare hostname of the PDS where the account will be created:
- Production:
selfhosted.social(or your PDS domain) - Local development:
localhost:2583
hatk will automatically prepend the correct scheme (https:// for production, http:// for localhost) and discover the PDS auth server via its protected resource metadata. The prompt=create parameter is forwarded to the PDS so it shows the signup page instead of the login page.
Server-side auth
parseViewer in layouts
Use parseViewer(cookies) in your +layout.server.ts to read the session cookie and pass the viewer to all routes:
// app/routes/+layout.server.ts
import { parseViewer } from "$hatk/client";
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ cookies }) => {
const viewer = await parseViewer(cookies);
return { viewer };
};parseViewer returns { did: string; handle?: string } if a valid session exists, or null if the user is not signed in. The viewer is then available in data.viewer on every page through SvelteKit's layout data flow.
ctx.viewer in handlers
In XRPC handlers and feed generators, the authenticated user is available as ctx.viewer:
import { defineQuery } from "$hatk";
export default defineQuery("my.app.getPrivateData", async (ctx) => {
if (!ctx.viewer) throw new Error("Authentication required");
const { did } = ctx.viewer;
const rows = await ctx.db.query(
`SELECT * FROM my_table WHERE did = $1`,
[did],
);
return ctx.ok({ items: rows });
});ctx.viewer is the same { did: string; handle?: string } shape in both XRPC handlers and feed generate/hydrate functions.
Complete example
Here's the full auth flow from config to login form to protected data.
1. Configure OAuth
// hatk.config.ts
import { defineConfig } from "@hatk/hatk/config";
export default defineConfig({
// ...
oauth: {
scopes: ["atproto repo:xyz.statusphere.status?action=create&action=delete"],
clients: [
{
client_id: "http://127.0.0.1:3000/oauth-client-metadata.json",
client_name: "statusphere",
scope: "atproto repo:xyz.statusphere.status?action=create&action=delete",
redirect_uris: ["http://127.0.0.1:3000/oauth/callback"],
},
],
},
});2. Parse the viewer in your layout
// app/routes/+layout.server.ts
import { parseViewer } from "$hatk/client";
import type { LayoutServerLoad } from "./$types";
export const load: LayoutServerLoad = async ({ cookies }) => {
const viewer = await parseViewer(cookies);
return { viewer };
};3. Build the login form
<!-- app/routes/+page.svelte -->
<script lang="ts">
import { login, logout } from '$hatk/client'
import { invalidateAll } from '$app/navigation'
let { data } = $props()
let handle = $state('')
async function doLogin() {
if (!handle.trim()) return
try {
await login(handle.trim())
} catch {
alert('Handle not found. Check spelling and try again.')
}
}
async function doLogout() {
await logout()
await invalidateAll()
}
</script>
{#if data.viewer}
<p>Signed in as {data.viewer.did}</p>
<button onclick={doLogout}>Sign out</button>
<!-- Authenticated content here -->
{:else}
<form onsubmit={(e) => { e.preventDefault(); doLogin() }}>
<input bind:value={handle} placeholder="Enter your handle (e.g. alice.bsky.social)" />
<button type="submit">Sign in</button>
</form>
{/if}4. Protect a server endpoint
// server/xrpc/getMyData.ts
import { defineQuery } from "$hatk";
export default defineQuery("my.app.getMyData", async (ctx) => {
if (!ctx.viewer) throw new Error("Authentication required");
const rows = await ctx.db.query(
`SELECT * FROM "xyz.statusphere.status" WHERE did = $1`,
[ctx.viewer.did],
);
return ctx.ok({ items: rows });
});