title: Auth description: Add authentication to your hatk app with OAuth and session cookies.#
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 cookie
Logout#
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.social
The 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 });
});