AuthKit Astro SDK
The AuthKit Astro SDK provides utilities for integrating AuthKit authentication into Astro applications.
Refer to the AuthKit documentation for detailed usage instructions and examples.
Beta Versions
Certain WorkOS features may be available only in the beta version of the SDK. Beta versions have the-beta.* suffix, for example,
3.2.0-beta.1. For more information on how to use beta
versions, refer to the README in the GitHub repository.
Official WorkOS AuthKit SDK for
Astro. Session validation, automatic token refresh,
PKCE sign-in, route protection, auth components, and a client-island auth
store — built on
@workos/authkit-session.
Requires an SSR / on-demand Astro app (an adapter such as @astrojs/node).
Individual pages may still opt into prerendering — see
Prerendered pages.
npx astro add node @workos/authkit-astro
AuthKit needs on-demand rendering, so install a server adapter (node here, or
your deployment target’s adapter) alongside the integration — astro add wires
up the adapter and output, then adds the integration to your config. Without
an adapter the integration throws at startup, since its middleware and routes
run per request. Or do it by hand:
// astro.config.mjs
import node from '@astrojs/node';
import authkit from '@workos/authkit-astro';
import { defineConfig } from 'astro/config';
export default defineConfig({
output: 'server',
adapter: node({ mode: 'standalone' }),
integrations: [authkit({ protectedRoutes: ['/dashboard'] })],
});
The integration auto-wires everything: the auth middleware, the
/login · /signup · /callback · /logout routes, the client session
endpoint, the astro:env secret schema, and the Astro.locals.auth
types. You also need to install the WorkOS Node SDK peer (npx handles this
for you with astro add):
npm add @workos-inc/node
Then provide the env vars (read at runtime via astro:env, never bundled):
WORKOS_CLIENT_ID=client_...
WORKOS_API_KEY=sk_test_...
WORKOS_REDIRECT_URI=http://localhost:4321/callback
WORKOS_COOKIE_PASSWORD=at-least-32-characters-long-secret # openssl rand -base64 32
Add http://localhost:4321/callback as a redirect URI in the
WorkOS dashboard. That’s it — /dashboard now
redirects anonymous visitors to sign-in, and Astro.locals.auth is populated
(and typed) everywhere.
---
const { auth } = Astro.locals;
---
{
auth.user ? (
<span>Signed in as {auth.user.email} · <a href="/logout">Sign out</a></span>
) : (
<a href="/login">Sign in</a>
)
}
Integration options
authkit({
protectedRoutes: ['/dashboard(.*)'], // prefixes or path-to-regexp 6.x patterns
signInPath: '/login', // where anonymous visitors are sent
loginPath: '/login', // patterns for the injected routes
signUpPath: '/signup',
callbackPath: '/callback',
logoutPath: '/logout',
afterSignOutUrl: '/', // where /logout lands (also accepts ?returnTo=)
errorRedirect: '/login', // redirect (with ?error=) on callback failure instead of a 400
sessionEndpoint: '/_authkit/me', // client store hydration endpoint
injectRoutes: true,
injectEnvSchema: true, // declare WORKOS_* in astro:env
hydrateClient: true, // session endpoint + client bootstrap script
});
protectedRoutes accepts plain prefixes (/dashboard also matches
/dashboard/...) and path-to-regexp 6.x patterns (/dashboard(.*),
/orgs/:slug, /orgs/:slug?,
/files/:path*, /users/:id(\\d+)). Plain prefixes match nested paths;
patterns match the full pathname. Anonymous browser navigations are redirected
to signInPath; non-HTML requests (e.g. fetch() from an island) get a 401
JSON response instead.
A discriminated union on user — checking if (auth.user) narrows
sessionId, accessToken, and claims to non-optional types.
auth.user; // User | null
auth.sessionId; // string (signed in)
auth.accessToken; // string (signed in; server only — never sent to the client)
auth.claims; // verified JWT claims, incl. custom claims (signed in)
auth.organizationId; // string | null
auth.role; // string | null
auth.roles; // string[]
auth.permissions; // string[]
auth.entitlements; // string[]
auth.featureFlags; // string[]
auth.impersonator; // Impersonator | null
auth.has({ role: 'admin' }); // boolean — also permission / entitlement / featureFlag; ANDs checks
auth.redirectToSignIn(); // Response — redirect to sign-in, returnTo = current URL
Guard a page or endpoint imperatively:
---
const { auth } = Astro.locals;
if (!auth.user) return auth.redirectToSignIn();
if (!auth.has({ permission: 'billing:manage' })) return new Response(null, { status: 403 });
---
Server-rendered control and button components (the unmatched branch never reaches the browser on request-rendered pages):
---
import {
AuthState, // synchronous client-store hydration (put in <head>)
Show, // <Show when={{ role: 'admin' }}> ... <p slot="fallback">…</p> </Show>
SignedIn, // children render only when signed in
SignedOut, // children render only when signed out
SignInButton, // <a> to the login route; props: path, returnTo, + anchor attrs
SignUpButton,
SignOutButton,
Impersonation, // fixed banner while the session is impersonated
UserButton, // account menu built from Astro.locals.auth.user
UserProfile, // server-rendered user summary
OrganizationSwitcher, // form for posting an active org switch
OrganizationProfile, // active organization summary
} from '@workos/authkit-astro/components';
---
<SignedIn>
Welcome back! <UserButton profilePath="/account" />
</SignedIn>
<SignedOut>
<SignInButton returnTo="/dashboard">Log in</SignInButton>
</SignedOut>
<Show when={{ permission: 'invoices:create' }}>
<a href="/invoices/new">New invoice</a>
<span slot="fallback">Ask an admin for access.</span>
</Show>
when accepts 'signed-in', 'signed-out', an object of
role/permission/entitlement/featureFlag checks (ANDed), or a predicate
(auth) => boolean (server-rendered pages only). On prerendered pages the
components defer to the client store via a tiny <authkit-gate> custom
element instead. Add serverOnly to <SignedIn>, <SignedOut>, or <Show>
when prerendered pages must not include gated children in their static HTML:
<SignedIn serverOnly>
<SecretAccountLink />
</SignedIn>
<Show when={{ permission: 'billing:manage' }} serverOnly>
<BillingAdminPanel />
<span slot="fallback">Not available.</span>
</Show>
The user and organization components are intentionally data-driven. UserButton
and UserProfile render from Astro.locals.auth.user. OrganizationProfile
renders the active organization id / role / permissions already present in the
session. OrganizationSwitcher needs the app to pass the organizations the
user can switch to, and posts the selected id to an endpoint you own:
<OrganizationSwitcher
action="/api/switch-org"
organizations={[
{ id: 'org_123', name: 'Acme' },
{ id: 'org_456', name: 'Globex' },
]}
/>
Read the session reactively from any island (React, Vue, Svelte, Preact, Solid, or vanilla) via nanostores — the same store works across frameworks. The store holds a client-safe snapshot (no access token).
Hydrate it synchronously by dropping <AuthState /> in your layout <head>:
---
// src/layouts/Layout.astro
import { AuthState } from '@workos/authkit-astro/components';
---
<head>
<AuthState />
</head>
(The integration also exposes /_authkit/me, so islands hydrate even without
<AuthState /> — just slightly later. With Astro’s <ClientRouter /> view
transitions, the store re-hydrates automatically after each navigation.)
Then read it in an island:
// React island
import { useStore } from '@nanostores/react';
import { $signedIn, $user } from '@workos/authkit-astro/client';
export function UserBadge() {
const user = useStore($user);
return <span>{user ? user.email : 'Signed out'}</span>;
}
The client entry exports the stores $auth, $user, $signedIn,
$isLoaded, $organizationId, $role, $permissions and the helpers
setAuthState() / hydrateAuth(). $isLoaded distinguishes “not hydrated
yet” from “signed out” — gate loading UI on it. Use the matching
@nanostores/{react,vue,svelte,...} binding for your island framework — or,
for React, the zero-dependency hooks:
import { useAuth, useUser } from '@workos/authkit-astro/react';
export function UserBadge() {
const { user, isLoaded } = useUser();
if (!isLoaded) return null;
return <span>{user ? user.email : 'Signed out'}</span>;
}
Prefer to wire things yourself (custom paths, full middleware control, no integration)? The building blocks are exported directly.
// src/middleware.ts
import { authkitMiddleware, configureAuthKit, createRouteMatcher } from '@workos/authkit-astro';
import { WORKOS_API_KEY, WORKOS_CLIENT_ID, WORKOS_COOKIE_PASSWORD, WORKOS_REDIRECT_URI } from 'astro:env/server';
configureAuthKit({
clientId: WORKOS_CLIENT_ID,
apiKey: WORKOS_API_KEY,
redirectUri: WORKOS_REDIRECT_URI,
cookiePassword: WORKOS_COOKIE_PASSWORD,
});
// Options form — string prefix, pattern, RegExp, or (pathname) => boolean:
export const onRequest = authkitMiddleware({
protectedRoutes: ['/dashboard(.*)', /^\/admin/],
});
Or take full per-request control with the handler form:
const isAdminRoute = createRouteMatcher(['/admin(.*)', '/orgs/:slug/admin']);
export const onRequest = authkitMiddleware((auth, context) => {
if (isAdminRoute(context.url) && !auth.has({ role: 'admin' })) {
return auth.redirectToSignIn();
}
});
// src/pages/login.ts (and signup.ts, logout.ts)
export { handleSignIn as GET } from '@workos/authkit-astro';
// src/pages/callback.ts — drop-in, or customized:
import { createCallbackHandler } from '@workos/authkit-astro';
export const GET = createCallbackHandler({
errorRedirect: '/login',
onSuccess: (_ctx, { authResponse }) => console.log('signed in', authResponse.user.id),
});
Switch the session’s active organization (refreshes the access token scoped to the org and persists the new session cookie):
// src/pages/api/switch-org.ts
import { switchToOrganization } from '@workos/authkit-astro';
export const POST: APIRoute = async (context) => {
const { organizationId } = await context.request.json();
const auth = await switchToOrganization(context, organizationId);
return Response.json({
organizationId: auth.organizationId,
role: auth.role,
});
};
Use that endpoint with <OrganizationSwitcher />:
---
import { OrganizationSwitcher } from '@workos/authkit-astro/components';
---
<OrganizationSwitcher
action="/api/switch-org"
organizations={[
{ id: 'org_123', name: 'Acme' },
{ id: 'org_456', name: 'Globex' },
]}
/>
The full WorkOS Node client, sharing the SDK’s configuration:
import { getWorkOS } from '@workos/authkit-astro';
const org = await getWorkOS().organizations.getOrganization(auth.organizationId);
Verify the workos-signature header and get a parsed event back:
// src/pages/api/webhooks.ts
import { verifyWebhook } from '@workos/authkit-astro';
export const POST: APIRoute = async (context) => {
const event = await verifyWebhook(context); // secret from WORKOS_WEBHOOK_SECRET
if (event.event === 'user.created') {
// ...
}
return new Response(null, { status: 200 });
};
Pages with export const prerender = true build without a request, so the
middleware skips session work there (locals.auth is signed-out and builds
don’t need the WORKOS_* secrets). On those pages:
<SignedIn>/<SignedOut>/<Show>defer to the client store and resolve after hydration (content is in the HTML — don’t put secrets in it).<AuthState />emits nothing; islands hydrate from the session endpoint.- Add
serverOnlyto<SignedIn>,<SignedOut>, or<Show>to render no gated children during prerendering. For<Show>, thefallbackslot is rendered instead.
| Export | Type | Purpose |
|---|---|---|
default (workos) | AstroIntegration | The astro add integration |
authkitMiddleware(options?) | MiddlewareHandler | Validate session → Astro.locals.auth, refresh tokens, gate routes |
authkitMiddleware(handler, options?) | MiddlewareHandler | Handler form: (auth, context, next) => for full per-request control |
createRouteMatcher(patterns) | (input) => bool | Reusable matcher for prefixes / patterns / RegExps / predicates |
configureAuthKit(config) | void | Provide config explicitly (e.g. from astro:env) |
getWorkOS() | WorkOS | The configured WorkOS Node client |
switchToOrganization(ctx, orgId) | Promise<auth> | Switch the session’s active organization |
getSignInUrl(ctx, opts?) | Promise<string> | Sign-in URL + writes PKCE verifier cookie (for custom links) |
getSignUpUrl(ctx, opts?) | Promise<string> | Sign-up URL variant |
handleSignIn / handleSignUp | APIRoute | Drop-in GET handlers that redirect to AuthKit |
handleCallback | APIRoute | Drop-in GET handler for the OAuth callback |
handleSignOut | APIRoute | Drop-in GET handler that clears the session and logs out |
createCallbackHandler(opts?) | () => APIRoute | Callback with errorRedirect / onSuccess / onError |
createSignOutHandler(opts?) | () => APIRoute | Sign-out with a custom afterSignOutUrl |
verifyWebhook(ctx, opts?) | Promise<Event> | Verify + parse a WorkOS webhook request |
@workos/authkit-astro/components | Astro components | AuthState, gates/buttons, impersonation, user/org components |
@workos/authkit-astro/client | nanostores | $auth, $user, $signedIn, $isLoaded, …, hydrateAuth |
@workos/authkit-astro/react | hooks | useAuth(), useUser() (no extra deps) |
- The middleware calls
withAuth()once per request, exposes the result onAstro.locals.auth, and persists a refreshed token when one is issued. - Cookies (session + the short-lived PKCE verifier) are read and written
through Astro’s native
context.cookies, which Astro flushes onto the response automatically — no manualSet-Cookiehandling. - Sign-in is PKCE-bound: the login route writes a per-flow verifier cookie that the callback verifies before exchanging the code.
returnTovalues are restricted to relative paths everywhere, so the login/logout flows can’t be used as open redirects.- The client store is hydrated from a client-safe projection of the session
(
toClientAuth) — the access token is never serialized to the browser.
A complete runnable app using the integration lives in example/.
MIT