> ## Documentation Index
> Fetch the complete documentation index at: https://fymo.mintlify.site/llms.txt
> Use this file to discover all available pages before exploring further.

# Authentication

> Identity resolvers, route and remote guards, and the client identity store, the mechanism Fymo gives you for owning your own auth.

Fymo owns no user model. Identity is just a `uid` string your app produces, and Fymo gives you the mechanism around it: resolvers, request-scoped caching, and guards for both routes and remote functions. There's no `User` class, no built-in user table, and no config block that turns auth on. You write a resolver, and identity exists.

## Identity resolvers

An identity resolver is a plain function: it takes a `ResolverEvent` and returns an `Identity`, or `None` for an anonymous request. Register one with `@identify`, and Fymo auto-discovers it from any file under `app/auth/` (skipping `__init__.py` and files starting with an underscore, the same convention `app/remote/` uses).

```python app/auth/resolver.py theme={null}
from fymo.auth import Identity, identify, sign_token, verify_token
from fymo.remote import clear_cookie, set_cookie

SESSION_COOKIE = "session"
SESSION_MAX_AGE = 7 * 24 * 60 * 60  # seconds


def start_session(uid: str) -> None:
    set_cookie(SESSION_COOKIE, sign_token(uid), max_age=SESSION_MAX_AGE, http_only=True)


def end_session() -> None:
    clear_cookie(SESSION_COOKIE)


@identify
def by_session_cookie(event):
    token = event.cookies.get(SESSION_COOKIE)
    if not token:
        return None
    uid = verify_token(token)
    return Identity(uid=uid) if uid else None
```

`Identity` is a frozen dataclass with one field, `uid`. `ResolverEvent` carries `remote_addr`, `cookies`, `headers`, and `scheme`, a narrow read-only view of the request rather than the whole thing. Resolvers run in filename-sorted registration order, and the first one to return a non-`None` `Identity` wins.

Read the resolved uid anywhere inside a request with `current_uid()`. It walks the resolver chain at most once per request and caches the result, including the anonymous outcome, so stacking several resolvers costs nothing extra.

```python theme={null}
from fymo.auth import current_uid

uid = current_uid()  # str, or None if no resolver claimed the request
```

<Note>
  Calling `current_uid()` outside a request raises `RuntimeError` rather than quietly returning `None`. A misplaced call fails loudly instead of looking like the visitor just isn't signed in.
</Note>

<Warning>
  `fymo.auth.current_uid()` is not the same thing as `fymo.remote.current_uid()`. The one in `fymo.remote` reads the anonymous `fymo_uid` tracking cookie every request gets, signed in or not. The one in `fymo.auth` is the identity your resolvers produced. Import from the module you actually mean.
</Warning>

There's no config key that turns identity on. Presence of any `@identify` resolver in `app/auth/` is what does it, and deleting the whole directory turns it back off.

## Protecting routes

Add `require_auth: true` to a route to redirect anonymous visitors before any server-side rendering starts. A route needs the dict form, with `to:`, once it carries any attribute:

```yaml fymo.yml theme={null}
routes:
  root:
    to: home.index
    require_auth: true
  signin: signin.index
```

For a `resources:` entry, use a dict list item with `name:`:

```yaml fymo.yml theme={null}
routes:
  resources:
    - name: admin
      require_auth: true
```

By convention, the route named `signin` is the redirect target and is always public. Every protected app needs one; leaving it out is a boot-time `ConfigurationError` that names the fix directly.

Verified against a live app: an anonymous request to a protected root gets `302` with `Location: /signin?next=%2F`. A request carrying a valid session cookie gets `200`. The `next` value is only ever the original path and query, never a full URL, so the signin page can send a visitor back where they came from.

`require_auth` also accepts a dotted path to a guard function instead of `true`:

```yaml fymo.yml theme={null}
routes:
  root:
    to: home.index
    require_auth: app.auth.guards.require_admin
```

The path must resolve to a zero-argument callable, checked at boot so a typo fails before your app ever serves a request. Fymo calls the guard only after confirming the visitor is signed in; any exception it raises redirects the same way anonymous access does.

<Tip>
  A route alias inherits the strictest `require_auth` declared anywhere for its controller, so hitting `/home` when only `root` is marked protected doesn't bypass the guard.
</Tip>

## Protecting remote functions

`@require_auth` wraps a remote function so a missing identity short-circuits into a 401 before the body runs:

```python app/remote/posts.py theme={null}
from fymo.auth import current_uid, require_auth
from fymo.remote import remote

@remote
@require_auth
def create_comment(slug: str, input: NewComment) -> Comment:
    uid = current_uid()  # never None past this point
    ...
```

It raises `AuthRequired`, a `RemoteError` the router already knows how to serialize as `{type: "error", status: 401, error: "unauthenticated"}`. If your app has a signin route, that envelope also carries a `signin` field. The generated client's fetch wrapper follows it automatically, navigating there with `?next=` instead of just throwing, the same redirect a protected page gives an anonymous visitor.

## Identity extras

`current_uid()` only ever gives you a string. Anything else about the signed-in caller, an email, a role, an organization, is app data you attach with `register_identity_extras_hook`. The hook runs once per request scope, right after a resolver returns a uid, and its result is available for the rest of the request through `identity_extras()`.

```python app/auth/extras.py theme={null}
from dataclasses import dataclass

from fymo.auth import current_uid, identity_extras, register_identity_extras_hook

from app.auth import store


@dataclass(frozen=True)
class Extras:
    email: str
    created_at: str


def _load_row(uid: str) -> dict:
    row = store.get_by_id(uid)
    if row is None:
        return {}
    return {"email": row["email"], "created_at": row["created_at"]}


register_identity_extras_hook(_load_row)


def current_extras() -> "Extras | None":
    if current_uid() is None:
        return None
    extras = identity_extras()
    if not extras:
        return None
    return Extras(email=extras["email"], created_at=extras["created_at"])
```

`identity_extras()` never raises for a missing hook or an unresolved identity, it just returns an empty mapping. It only raises the way `current_uid()` does, when called outside a request scope entirely.

## The identity store

Extras stay on the server by default. What the browser sees is whatever your `@public_identity` projection returns, and nothing else. Register exactly one:

```python app/auth/public.py theme={null}
from fymo.auth import identity_extras, public_identity

@public_identity
def project(ident):
    email = identity_extras().get("email") or ""
    return {"uid": ident.uid, "name": email.split("@", 1)[0] or None}
```

Treat this as a whitelist, not a spread. Whatever the function returns is embedded in every page's HTML for that signed-in visitor, so add fields one at a time and leave out anything sensitive. Register nothing, and a signed-in visitor sees the safe default, `{"uid": ...}` alone.

The projection is exposed to Svelte as the `identity` store from `$auth`, hydrated at SSR and refreshed on every soft navigation:

```svelte theme={null}
<script>
  import { identity } from '$auth'
</script>

{#if $identity}
  Hi {$identity.name}
{:else}
  <a href="/signin">Sign in</a>
{/if}
```

<Tip>
  The scaffolded `identity` store is enough for most apps. The reference blog app builds a richer client-side auth store on top of `$remote/auth` instead (see `app/lib/auth.ts` there), which is one valid way to grow past the default once you need more than a signed-in flag.
</Tip>

## Generating auth

`fymo new` scaffolds working password auth by default: `app/auth/`, `app/remote/auth.py`, and a `/signin` page, ready to sign up and sign in against right away. Pass `--no-auth` to skip it and add auth later.

`fymo generate auth` writes the identity code into an existing project. Two flags change what it generates:

| Command                         | Generates                                                                                       | Notes                                                                                                                                             |
| ------------------------------- | ----------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
| `fymo generate auth`            | `app/auth/{__init__,resolver,store,extras,public}.py`, `app/remote/auth.py`, `schema/users.sql` | Session-cookie password auth, sqlite-backed by default.                                                                                           |
| `fymo generate auth --clerk`    | `app/auth/{__init__,resolver}.py` only                                                          | A Clerk JWT resolver. Your app owns the `pyjwt[crypto]` dependency; the import is lazy, so it loads fine without it until the first real request. |
| `fymo generate auth --skeleton` | `app/auth/{__init__,resolver}.py` only                                                          | A stub `@identify` resolver returning `None`, with a comment cheat-sheet for session-cookie and API-key patterns.                                 |

The signin page itself is the one piece `fymo generate auth` doesn't write in an existing project (a fresh `fymo new` ships it). The command's own next-steps say exactly what to add: a `signin: signin.index` route, a controller, and a form calling the `login` remote function. `fymo generate page signin` scaffolds the route and both files; the form body is yours to write.

The `--clerk` resolver reads `CLERK_ISSUER` from the environment, or derives it from `PUBLIC_CLERK_PUBLISHABLE_KEY` when that's unset, and verifies RS256 JWTs against `{issuer}/.well-known/jwks.json`. It namespaces the resolved uid as `clerk:<sub>`, so it can never collide with a uid from another mechanism.

The bare (password) generator's `app/remote/auth.py` gives you `signup`, `login`, `logout`, and `me`. Passwords are hashed with scrypt, a standard-library algorithm needing no extra dependency, bounded to 8-1024 characters with no character-class rules. Login returns one generic 401 for both an unknown email and a wrong password, so response timing can't be used to guess which accounts exist.

<Warning>
  Setting an `auth:` block in `fymo.yml` is a hard boot error now. Identity lives in code, in `app/auth/`, not in config. Run `fymo generate auth` and delete the `auth:` block.
</Warning>

## Cookies

`set_cookie` and `clear_cookie` live in `fymo.remote`, not `fymo.auth`. They queue a `Set-Cookie` header for the response, drained by the router once your function returns, so you never touch the WSGI response directly:

```python theme={null}
from fymo.remote import clear_cookie, set_cookie

set_cookie("session", token, max_age=SESSION_MAX_AGE, http_only=True)
clear_cookie("session")
```

These are the primitives the generated `start_session`/`end_session` helpers in `app/auth/resolver.py` wrap. Reach for them directly if you're writing a resolver of your own.

## Testing identities

`fymo.testing.signed_in` simulates an authenticated caller for a remote function called directly, bypassing the WSGI layer entirely. `acting_as`, nested inside it, swaps in a second identity mid-test, which is what makes it possible to prove one user can't see or touch another's data:

```python theme={null}
from fymo.testing import acting_as, signed_in

def test_second_user_cannot_comment_as_the_first(db):
    from app.remote.posts import NewComment, create_comment, get_comments

    slug = _seed_post(db)
    with signed_in("u_alice", extras={"email": "alice@example.com", "created_at": "2026-01-01T00:00:00Z"}):
        create_comment(slug, input=NewComment(body="alice's take"))
        with acting_as("u_bob", extras={"email": "bob@example.com", "created_at": "2026-01-01T00:00:00Z"}):
            bobs_comment = create_comment(slug, input=NewComment(body="bob's reply"))
        assert bobs_comment["name"] == "bob"
        comments = get_comments(slug)
    assert {c["name"] for c in comments} == {"alice", "bob"}
```

This is the real test from the reference blog app, and it passes against the actual identity chain. `signed_in` registers a resolver through the same `@identify` mechanism a live request uses, so there's no separate mock path to drift out of sync with production behavior.

For a deeper look at `init_providers` and testing storage, jobs, and broadcasts alongside identity, see [Testing your app](/testing-your-app).
