Skip to content

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

  1. User enters their handle, frontend calls login(handle)
  2. Browser redirects to the user's PDS for authorization
  3. PDS redirects back to your server, which completes the token exchange
  4. Server sets an encrypted session cookie
  5. On subsequent requests, parseViewer(cookies) reads the cookie to identify the user

Configuration

Enable OAuth in hatk.config.ts by adding an oauth section:

typescript
// 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

FieldDescription
issuerYour app's public URL. Used for OAuth metadata discovery. Optional in dev.
scopesArray of OAuth scopes your app needs
clientsArray 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:

typescript
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:

typescript
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:

typescript
import { logout } from "$hatk/client";

await logout();

Login form example

A minimal Svelte login form using login and logout:

svelte
<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:

typescript
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:

typescript
// 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:

typescript
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

typescript
// 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

typescript
// 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

svelte
<!-- 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

typescript
// 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 });
});