dioxus-clerk 0.5.0

Clerk integration for Dioxus: components, hooks, and SSR initial state for web and fullstack apps
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
# Testing

Most tests that involve Clerk are not *about* Clerk. You want a signed-in user
so you can test what your application does. This guide starts there, then
covers testing the auth behaviour itself, and finally testing through real
Clerk when you need it.

## Choosing an approach

| | What it needs | Clerk UI | User IDs | Good for |
| --- | --- | --- | --- | --- |
| [`TestClerk`]#testing-your-application | Nothing | Not rendered | You choose them | Your app's logic, guards, org-scoped views. The default. |
| [`TestIssuer` / `TestSession`]#testing-authentication-itself | Nothing | Not rendered | You choose them | Auth behaviour: permissions, expiry, what gets rejected. |
| [`@clerk/testing`]#through-real-clerk | Live Clerk instance, secrets, network | Real and drivable | Assigned by Clerk | Sign-in/sign-up flows, MFA — Clerk's own UI. |

The first two are the same offline machinery at different altitudes: no Clerk
instance, no network, no secrets in CI. Reach for the third only for the
handful of tests that go *through* Clerk's interface.

Tokens from all three are equally valid to your backend — the real-Clerk mode
issues genuine Clerk-signed session tokens, and the offline modes issue tokens
your layer is configured to trust. The catch is that a given server process
trusts [one family or the other](#the-two-modes-cannot-share-a-server), not both.

---

## Setup

Add the crate under dev-dependencies with the `testing` feature. Keep it out of
`[dependencies]` — it mints tokens a correctly configured verifier accepts.

```toml
[dependencies]
dioxus-clerk = { version = "0.4", features = ["server"] }

[dev-dependencies]
dioxus-clerk = { version = "0.4", features = ["server", "testing"] }
```

### Environment variables

**None are required.** No `CLERK_SECRET_KEY`, no `CLERK_PUBLISHABLE_KEY`,
nothing in CI secrets.

Two caveats:

- If your application calls `ClerkAuthLayerConfig::from_env()` in its own
  wiring, tests must not go through that path. See
  [Wiring your app for tests]#wiring-your-app-for-tests.
- A `.env` file with real Clerk keys can leak into a test run and make failures
  confusing. Prefer an explicit test config over ambient environment.

---

## Testing your application

`TestClerk` is the whole API when auth is a precondition rather than the
subject. It generates a key, wires a layer that verifies against it, and hands
you credentials:

```rust,ignore
use dioxus_clerk::testing::TestClerk;

#[tokio::test]
async fn the_dashboard_lists_the_users_projects() {
    let clerk = TestClerk::new().unwrap();
    let app = my_app::router(clerk.layer().unwrap());

    let response = app
        .oneshot(
            Request::builder()
                .uri("/dashboard")
                .header(header::COOKIE, clerk.cookie("user_2abc").unwrap())
                .body(Body::empty())
                .unwrap(),
        )
        .await
        .unwrap();

    assert_eq!(response.status(), StatusCode::OK);
    // ...assert on what your app actually rendered.
}
```

That's the entire surface for this case:

| Call | Gives you |
| --- | --- |
| `clerk.layer()` | A `ClerkAuthLayer` that verifies this setup's tokens and never hits the network. |
| `clerk.cookie(user_id)` | A `__session=<token>` header value — what Clerk's browser SDK sends. |
| `clerk.bearer(user_id)` | An `Authorization: Bearer <token>` value. Verification accepts either. |
| `clerk.config()` | The layer config, if you need to harden it before building the layer. |

An unauthenticated request is just a request with no cookie — nothing special
needed.

### Share one setup

RSA-2048 generation costs ~100ms and is variable. Fine once per suite, wasteful
per test:

```rust,ignore
use std::sync::LazyLock;
use dioxus_clerk::testing::TestClerk;

static CLERK: LazyLock<TestClerk> = LazyLock::new(|| TestClerk::new().expect("test Clerk"));
```

Each integration test binary is its own process and gets its own key, which is
fine — each also builds its layer from that same setup.

### Wiring your app for tests

The one structural change worth making: let the router take a layer instead of
building one from the environment internally.

```rust,ignore
// Production wiring reads the environment; tests hand in a test layer.
pub fn router(auth: ClerkAuthLayer) -> Router {
    Router::new()
        .route("/dashboard", get(dashboard))
        .layer(auth)
}

pub fn router_from_env() -> Result<Router, ClerkError> {
    Ok(router(ClerkAuthLayer::from_config(
        ClerkAuthLayerConfig::from_env()?,
    )?))
}
```

### Signing in as someone specific

`clerk.session(user_id)` returns a `TestSession` to customize, and
`cookie_for` / `bearer_for` / `token_for` sign it:

```rust,ignore
let admin = clerk
    .session("user_admin")
    .with_organization("org_acme")
    .with_organization_role("org:admin")
    .with_organization_permissions(["org:dashboard:manage"]);

let cookie = clerk.cookie_for(&admin).unwrap();
```

### Choosing user IDs

You pick the user ID, and that is a large part of why this mode suits
application tests. If your app keys data on the user — rows, ownership checks,
seeded fixtures — a chosen ID lets the fixtures hardcode it. Nothing has to be
discovered at runtime, and nothing is coupled to a Clerk instance's internal
state.

Verification only requires `sub` to be non-empty; there is no format check and
no `user_` prefix requirement. Use obviously-synthetic, `user_`-prefixed IDs
anyway, so nothing in your app trips on a format assumption and a test ID is
never mistaken for a real one in a log:

```rust,ignore
pub const TEST_ADMIN: &str = "user_test_admin";
pub const TEST_MEMBER: &str = "user_test_member";
```

The contrast is with [`@clerk/testing`](#through-real-clerk), where the ID is
assigned by Clerk, opaque, and changes if the test user or instance is ever
recreated. If your test needs a *specific* user, mint the token here rather
than signing in through Clerk.

### SSR tests

Server-rendered output for a signed-in user needs no token, no layer, and no
key — construct the initial state directly:

```rust,ignore
use dioxus_clerk::core::ClerkAuth;
use dioxus_clerk::ssr::{InitialAuthSnapshot, InitialState};

let mut auth = ClerkAuth::new("user_2abc", 9_999_999_999);
auth.session_id = Some("sess_2def".into());
auth.org_role = Some("org:admin".into());

let signed_in = InitialState::new(InitialAuthSnapshot::from(&auth), Some("pk_test_..."));
let signed_out = InitialState::new(InitialAuthSnapshot::signed_out(), Some("pk_test_..."));
```

This is the cheapest way to assert that `<SignedIn>` / `<SignedOut>` /
`<Protect>` render the right branch, and that hydration starts from the right
state instead of flashing unauthenticated content.

---

## Testing authentication itself

When the auth behaviour *is* the subject, drop to `TestIssuer` and
`TestSession`. `TestClerk::issuer()` exposes the issuer if you started with the
wrapper.

```rust,ignore
use dioxus_clerk::server::{ClerkAuthLayer, ClerkAuthLayerConfig};
use dioxus_clerk::testing::{TestIssuer, TestSession};

let issuer = TestIssuer::generate()?;
let layer = ClerkAuthLayer::from_config(
    ClerkAuthLayerConfig::new("").with_static_jwks(issuer.jwks_json()?),
)?;
```

`with_static_jwks` is what makes this offline: the layer verifies against a
fixed keyset, so no HTTP client is built and nothing is fetched. The secret key
goes unused and can be empty.

> `with_static_jwks` is not test-only — it also pins signing keys in
> production. But a fixed keyset does not rotate, so tokens signed by a newly
> rotated Clerk key are rejected until you update it. Prefer the fetched default.

### Organizations and permissions

Permissions go in as plain `org:<feature>:<permission>` strings and are encoded
into Clerk's packed v2 `fea`/`per`/`fpm` claims for you — the shape the
verifier decodes back into `ClerkAuth::org_permissions`:

```rust,ignore
TestSession::new("user_2abc")
    .with_organization("org_2ghi")
    .with_organization_slug("acme")
    .with_organization_role("org:admin")
    .with_organization_permissions(["org:dashboard:read", "org:teams:manage"])
```

The role accepts `admin` or `org:admin`; both normalize to `org:admin`. A
permission not in the three-part form errors at `sign()` rather than silently
decoding to an empty list.

For the older flat `org_id` / `org_slug` / `org_role` / `org_permissions`
claims, add `.with_v1_organization_claims()`. Clerk issues v2 now; use v1 only
to cover the legacy shape deliberately.

### Testing what should be rejected

Guards are only proven by the requests they turn away:

```rust,ignore
// Past its `exp`, well beyond the configured clock skew.
TestSession::new("user_2abc").expired()

// No `sid` — the shape of a Clerk JWT-template token, rejected by default so a
// leaked template token cannot be replayed as a session.
TestSession::new("user_2abc").without_session_id()

// Fails a configured add_authorized_party / add_issuer / add_audience.
TestSession::new("user_2abc").with_authorized_party("https://evil.example.com")

// Anything else, including deliberately malformed claims.
TestSession::new("user_2abc").with_claim("sub", serde_json::Value::Null)
```

A token signed by a second `TestIssuer::generate()` covers the
foreign-signature case.

Note that `with_static_jwks` can never produce
`VerificationOutcome::Unavailable` — there is nothing to fetch, so nothing can
be unavailable, and an unknown `kid` is reported as invalid. For the
unavailable path you need the fetching path below.

### Testing the JWKS fetching path

Skip this unless you specifically care about cache, refresh, or outage
behaviour — that logic lives in this crate and is covered by its own tests. If
you do want it, serve the issuer's JWKS from a mock server:

```rust,ignore
let issuer = TestIssuer::generate().unwrap();
let server = wiremock::MockServer::start().await;
wiremock::Mock::given(wiremock::matchers::method("GET"))
    .and(wiremock::matchers::path("/v1/jwks"))
    .respond_with(
        wiremock::ResponseTemplate::new(200).set_body_string(issuer.jwks_json().unwrap()),
    )
    .mount(&server)
    .await;

let config = ClerkAuthLayerConfig::new("sk_test_unused")
    .with_insecure_backend_api_base_url(format!("{}/v1", server.uri()));
```

The JWKS endpoint is the base URL plus `/jwks`.
`with_insecure_backend_api_base_url` is what permits a plain `http://` URL; it
logs a warning, by design.

---

## Browser tests

Two modes, and they cannot share a browser context — pick per Playwright
project.

### Offline (testing your app)

This is the browser counterpart to `TestClerk`: a fake `window.Clerk` for the
client, a real minted cookie for the server.

#### Why a fake is required

`ClerkProvider` injects clerk-js (and `@clerk/ui`) from your instance's
Frontend API host. In a test environment that is a real network fetch, and
without one the provider never becomes loaded.

The escape hatch is built into the loader: **script injection is skipped
entirely when `window.Clerk` already exists.** Install a fake before the app
boots and nothing is ever fetched.

The check is specific — `window.Clerk` must exist *and* `window.Clerk.load`
must be a function. A global without a callable `load` reads as "clerk-js has
not executed yet", and the loader injects the real script anyway.

#### The surface your fake must implement

| Member | Required | Notes |
| --- | --- | --- |
| `load(opts)` | **Yes** | Must be a function returning a Promise. Its presence is how the crate detects clerk-js. |
| `loaded` | **Yes** | Boolean. The provider is not loaded until this is `true`. |
| `addListener(cb)` | **Yes** | Must return an unsubscribe **function**; returning `undefined` breaks the provider. |
| `isSignedIn` | Recommended | Boolean. Falls back to "is `user` present" when absent. |
| `user` | If signed in | Needs `id`. Optional: `firstName`, `lastName`, `imageUrl`, `primaryEmailAddress.emailAddress`. |
| `session` | If signed in | Needs **both** `id` and `status` (`"active"` or `"pending"`). Optional: `getToken()`, `lastActiveOrganizationId`, `tasks`. |
| `signOut(opts)` | If used | Returns a Promise. |
| `mount*` / `unmount*` | If used | `mountSignIn`, `mountUserButton`, … one per Clerk widget you render. |
| `open*` / `close*` | If used | `openSignIn`, `closeUserProfile`, … |
| `redirectToSignIn` / `redirectToSignUp` | If used | Returns a Promise. |

A `Proxy` covers the long tail of widget methods without enumerating them:

```js
// tests/browser/clerk-fake.js
export function installClerkFake({ signedIn = true, userId = "user_2abc" } = {}) {
  const clerk = {
    loaded: true,
    isSignedIn: signedIn,
    user: signedIn
      ? {
          id: userId,
          firstName: "Test",
          lastName: "User",
          primaryEmailAddress: { emailAddress: "test@example.com" },
        }
      : null,
    session: signedIn
      ? { id: `sess_${userId}`, status: "active", tasks: [], getToken: async () => null }
      : null,
    load: async () => {},
    // Must return a function: the provider stores it as the unsubscribe.
    addListener: () => () => {},
    signOut: async () => {},
  };

  // Auto-stub every widget method so rendering a Clerk component is a no-op
  // instead of a TypeError.
  window.Clerk = new Proxy(clerk, {
    get(target, property) {
      if (property in target) return target[property];
      if (typeof property === "string" && /^(mount|unmount|open|close|redirectTo)/.test(property)) {
        return () => Promise.resolve();
      }
      return undefined;
    },
  });
}
```

#### Sharing a key between the server and Playwright

The Rust server and the Node process need the same key. Put it on disk once and
have both sides read it:

```rust,ignore
let issuer = TestIssuer::from_pem_file_or_generate("target/clerk-test-key.pem")?;
let clerk = TestClerk::from_issuer(issuer);
let layer = clerk.layer()?;
```

`from_pem_file_or_generate` creates the key on first use and reuses it after, so
a fresh checkout needs no setup step. Concurrent first runs converge on one key
rather than each signing with its own. Gitignore the path:

```gitignore
/target/clerk-test-key.pem
```

Node cannot sign Clerk tokens without reimplementing the claim shapes, so mint
them in Rust and hand them over:

```rust,ignore
// examples/mint-test-tokens.rs
use dioxus_clerk::testing::{TestClerk, TestIssuer};

fn main() -> Result<(), Box<dyn std::error::Error>> {
    let clerk = TestClerk::from_issuer(TestIssuer::from_pem_file_or_generate(
        "target/clerk-test-key.pem",
    )?);

    // Keyed by user id: the browser side looks a token up by the same constant
    // it hands the fake, so the cookie and the fake cannot drift apart.
    let long = std::time::Duration::from_secs(3600);
    let tokens = serde_json::json!({
        "user_test_admin": clerk.token_for(
            &clerk.session("user_test_admin")
                .with_organization("org_acme")
                .with_organization_role("org:admin")
                .with_lifetime(long),
        )?,
        "user_test_member": clerk.token_for(
            &clerk.session("user_test_member")
                .with_organization("org_acme")
                .with_organization_role("org:member")
                .with_lifetime(long),
        )?,
    });

    std::fs::write("target/test-tokens.json", serde_json::to_vec_pretty(&tokens)?)?;
    Ok(())
}
```

Give tokens a lifetime that outlives the suite. The default is one minute,
matching Clerk's real session tokens — long enough for a server test, not for a
browser run.

#### Playwright configuration

```js
// playwright.config.js
export default defineConfig({
  globalSetup: "./tests/browser/global-setup.js",
  use: { baseURL: "http://127.0.0.1:8080" },
  webServer: {
    command: "cargo run --features server",
    url: "http://127.0.0.1:8080",
    reuseExistingServer: !process.env.CI,
  },
});
```

```js
// tests/browser/global-setup.js
import { execFileSync } from "node:child_process";

export default function globalSetup() {
  execFileSync("cargo", ["run", "--quiet", "--example", "mint-test-tokens"], {
    stdio: "inherit",
  });
}
```

```js
import { readFileSync } from "node:fs";
import { installClerkFake } from "./clerk-fake.js";

const tokens = JSON.parse(readFileSync("target/test-tokens.json", "utf8"));

// One constant drives both halves — see the note below.
const USER = "user_test_admin";

test.beforeEach(async ({ context, baseURL }) => {
  // addInitScript runs before any page script, on every navigation — which is
  // what stops the provider from injecting real clerk-js.
  await context.addInitScript(installClerkFake, { userId: USER });

  await context.addCookies([
    { name: "__session", value: tokens[USER], url: baseURL, httpOnly: true, sameSite: "Lax" },
  ]);
});
```

Three things to get right:

- **`addInitScript` on the `context`, not the `page`.** It must run before the
  app's own scripts, on every navigation.
- **The cookie is what the server sees.** The fake only satisfies the client;
  the `__session` cookie is what the middleware verifies. They can disagree —
  which is itself worth a test.
- **Keep the two user IDs in sync.** The identity lives in two places: the `sub`
  of the minted cookie, and `user.id` in the fake. If they drift, the server
  thinks you are one user while the client renders another — a confusing failure
  that looks like a caching bug. Drive both from one constant, as above.

#### The publishable key

Your app still needs one for `ClerkProvider`, but with the fake installed the
key's Frontend API host is never contacted. Use a well-formed placeholder —
`pk_test_` followed by base64 of `<host>$` — so nothing falls down a
malformed-key path:

```text
# base64("test.clerk.accounts.dev$")
pk_test_dGVzdC5jbGVyay5hY2NvdW50cy5kZXYk
```

#### What this does not cover

The fake reports state; it does not render Clerk's UI. `mountSignIn` is a
no-op, so `<SignIn />` produces an empty container. You can test everything
*around* authentication — routing, guards, org-scoped views, signed-in and
signed-out branches — but not a user typing into Clerk's sign-in form.

### Through real Clerk

For that last case, Clerk ships
[`@clerk/testing`](https://clerk.com/docs/guides/development/testing/playwright/overview),
which drives a real development instance. There is nothing for this crate to
integrate: it is an npm package that operates on `window.Clerk` in the page, so
it composes with `dioxus-clerk` rather than plugging into it.

It composes for a specific reason worth knowing: `ClerkProvider` registers
`Clerk.addListener` and, on every event, **re-reads the full state from
`window.Clerk`** rather than trusting the event payload. A session established
by external code — which is exactly what `clerk.signIn()` does via `setActive`
— therefore propagates into the provider automatically.

```bash
npm install --save-dev @clerk/testing
```

```js
// tests/browser/clerk.setup.js
import { clerkSetup } from "@clerk/testing/playwright";

export default async function globalSetup() {
  // Fetches a Testing Token so Clerk's bot detection does not block automation.
  await clerkSetup();
}
```

```js
import { clerk, setupClerkTestingToken } from "@clerk/testing/playwright";

test("a user can sign in through Clerk's form", async ({ page }) => {
  await setupClerkTestingToken({ page });

  // Required: land on an unprotected page that mounts ClerkProvider first.
  await page.goto("/");
  await clerk.loaded({ page });

  await clerk.signIn({
    page,
    signInParams: { strategy: "password", identifier: "...", password: "..." },
  });

  await expect(page.getByRole("heading", { name: "Dashboard" })).toBeVisible();
});
```

#### Backend calls work unchanged

`clerk.signIn()` does not fake a session — it establishes a **real** one on a
real instance, so clerk-js sets a real `__session` cookie carrying a genuine
Clerk-signed JWT. Your middleware verifies it exactly as in production: normal
`from_env()` config, real JWKS fetch, no `with_static_jwks`, nothing special to
configure. Requests from these tests are indistinguishable from real traffic.

The distinction that trips people up: **the Testing Token is not the session
token.** It is a short-lived, instance-scoped credential passed as a
`__clerk_testing_token` query parameter on *Frontend API* requests, and its only
job is [bypassing bot detection](https://clerk.com/docs/guides/development/testing/overview)
so automation is not blocked. It never reaches your backend.

#### The two modes cannot share a server

The server's configuration decides which token family verifies, and it is one or
the other per process:

| Server config | Minted tokens | Real Clerk tokens |
| --- | --- | --- |
| `with_static_jwks` | Verify | Rejected |
| `from_env()` (real keys) | Rejected | Verify |

So this is not a per-test choice — it is per server process, and therefore per
Playwright project, each pointed at a differently-configured backend. (The
browser context differs too: the offline fake exists specifically to stop real
clerk-js from loading.)

#### What this mode costs

- A live Clerk **development instance**, with `CLERK_PUBLISHABLE_KEY` and
  `CLERK_SECRET_KEY` available to the test run — including in CI. On a public
  repository, fork pull requests cannot read repository secrets, so these tests
  fail for outside contributions.
- Network round-trips on every sign-in.
- User IDs are assigned by Clerk, not chosen. If your app keys data on the user,
  resolve the ID in `globalSetup` via the Backend API and seed from it — or use
  the offline mode, where you [pick the ID]#choosing-user-ids.
- Real users in a real instance means shared, mutable state: seeded accounts,
  isolation between parallel tests, and cleanup.
- `signInParams` [handles first-factor verification only]https://clerk.com/docs/guides/development/testing/playwright/test-helpers;
  MFA needs the `emailAddress` strategy, which uses the secret key server-side.
- Concurrency has been a rough edge —
  [clerk/javascript#7891]https://github.com/clerk/javascript/issues/7891
  reports `signIn()` timing out under `--workers=2+`, with `--workers=1` as the
  workaround. That issue is closed with a related fix, so verify against your
  own suite rather than assuming either way.
- Against a *production* instance, code-based auth (OTP) does not work with
  Testing Tokens; only email/password or direct email sign-in.

Keep this mode to the few tests that genuinely exercise Clerk's UI, and use the
offline mode for everything else.

---

## Troubleshooting

| Symptom | Cause |
| --- | --- |
| Test hangs; network request to `*.clerk.accounts.dev` | The fake was installed too late, or `Clerk.load` is not a function. Both make the loader inject the real script. Use `context.addInitScript`. |
| Provider never becomes loaded | `loaded` is not `true`, or `addListener` returned something other than a function. |
| Client shows signed out, server sees a valid user | The `__session` cookie is set but the `window.Clerk` fake is signed out or missing. They are independent. |
| Client and server disagree about *who* the user is | The fake's `user.id` and the token's `sub` have drifted. Drive both from one constant. |
| Real Clerk tokens rejected, or minted tokens rejected | A server process trusts one family, not both. Check whether it was built with `with_static_jwks` or real credentials. |
| Every request is 401 after a while | Token expired. The default lifetime is 60 seconds; set `with_lifetime` for browser runs. |
| `VerificationOutcome::Unavailable` / HTTP 503 | The layer is still fetching a JWKS. `with_static_jwks` was not applied, or the config was rebuilt from the environment somewhere. |
| Token rejected with no obvious reason | A `sid` is required by default. `without_session_id()` and real JWT-template tokens are rejected unless `allow_non_session_tokens()` is set. |
| `org_permissions` comes back empty | Permissions must be `org:<feature>:<permission>`. Malformed ones error at `sign()`; if you hand-built claims, check the `fea`/`per`/`fpm` encoding. |
| Key regenerated on every run | The path is inside a cleaned directory, or CI does not persist it. Fine in itself — just make sure the server and the minting step use the same one. |

## See also

- [`TestClerk` API docs]https://docs.rs/dioxus-clerk/latest/dioxus_clerk/testing/struct.TestClerk.html
- [`TestIssuer` API docs]https://docs.rs/dioxus-clerk/latest/dioxus_clerk/testing/struct.TestIssuer.html
- [`with_static_jwks`]https://docs.rs/dioxus-clerk/latest/dioxus_clerk/server/struct.ClerkAuthLayerConfig.html#method.with_static_jwks
- [Testing with Playwright — Clerk docs]https://clerk.com/docs/guides/development/testing/playwright/overview