
Security News
Happy Birthday, Shai-Hulud
It has been one year since Shai-Hulud made its first appearance on npm.
@workos/authkit-astro
Advanced tools
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>
)
}
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.
Astro.locals.authA 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.serverOnly to <SignedIn>, <SignedOut>, or <Show> to render no
gated children during prerendering. For <Show>, the fallback slot 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) |
withAuth() once per request, exposes the result on
Astro.locals.auth, and persists a refreshed token when one is issued.context.cookies, which Astro flushes onto the
response automatically — no manual Set-Cookie handling.returnTo values are restricted to relative paths everywhere, so the
login/logout flows can't be used as open redirects.toClientAuth) — the access token is never serialized to the browser.A complete runnable app using the integration lives in example/.
MIT
FAQs
Official WorkOS AuthKit SDK for Astro
The npm package @workos/authkit-astro receives a total of 4,025 weekly downloads. As such, @workos/authkit-astro popularity was classified as popular.
We found that @workos/authkit-astro demonstrated a healthy version release cadence and project activity because the last version was released less than a year ago. It has 6 open source maintainers collaborating on the project.

Security News
It has been one year since Shai-Hulud made its first appearance on npm.

Research
/Security News
Operators behind PolinRider used a compromised GitHub account to plant malware in four development versions of a Packagist package with 700,000+ downloads.

Security News
GitHub Actions now supports cache-mode, a least-privilege control on the Actions cache aimed at the cache poisoning technique behind recent compromises.