Warning
This is prerelease software. APIs may change without notice.
Toolkit for building WorkOS AuthKit framework integrations.
Handles JWT verification, session encryption, and token refresh orchestration. You build the framework-specific glue.
pnpm add @workos/authkit-session| Layer | Class | Purpose |
|---|---|---|
| Core | AuthKitCore | JWT verification (JWKS with caching), session encryption (AES-256-CBC), token refresh orchestration |
| Operations | AuthOperations | WorkOS API calls: signOut, refreshSession, authorization URLs |
| Helpers | CookieSessionStorage | Base class with secure cookie defaults |
| Orchestration | AuthService | Reference implementation combining all layers |
WORKOS_CLIENT_ID=your-client-id
WORKOS_API_KEY=your-api-key
WORKOS_REDIRECT_URI=https://yourdomain.com/auth/callback
WORKOS_COOKIE_PASSWORD=must-be-at-least-32-characters-long-secretOr programmatically:
import{configure}from'@workos/authkit-session';configure({clientId: 'your-client-id',apiKey: 'your-api-key',redirectUri: 'https://yourdomain.com/auth/callback',cookiePassword: 'must-be-at-least-32-characters-long-secret',});import{CookieSessionStorage}from'@workos/authkit-session';exportclassMyFrameworkStorageextendsCookieSessionStorage<Request,Response>{asyncgetCookie(request: Request,name: string): Promise<string|null>{constheader=request.headers.get('cookie');if(!header)returnnull;for(constpartofheader.split(';')){const[k, ...rest]=part.trim().split('=');if(k===name)returndecodeURIComponent(rest.join('='));}returnnull;}// Optional: override if your framework can mutate responsesprotectedasyncapplyHeaders(response: Response|undefined,headers: Record<string,string|string[]>,): Promise<{response: Response}>{constnewResponse=response
? newResponse(response.body,{status: response.status,statusText: response.statusText,headers: newHeaders(response.headers),})
: newResponse();for(const[key,value]ofObject.entries(headers)){if(Array.isArray(value)){for(constvofvalue)newResponse.headers.append(key,v);}else{newResponse.headers.append(key,value);}}return{response: newResponse};}}CookieSessionStorage provides this.cookieName, this.cookieOptions, and generic setCookie/clearCookie/serializeCookie primitives. getSession/saveSession/clearSession are one-line wrappers — you only implement getCookie.
import{createAuthService}from'@workos/authkit-session';exportconstauthService=createAuthService({sessionStorageFactory: config=>newMyFrameworkStorage(config),});exportconstauthMiddleware=()=>{returncreateMiddleware().server(asyncargs=>{const{ auth, refreshedSessionData }=awaitauthService.withAuth(args.request,);constresult=awaitargs.next({context: {auth: ()=>auth},});// CRITICAL: Persist refreshed tokens to cookieif(refreshedSessionData){const{ headers }=awaitauthService.saveSession(undefined,refreshedSessionData,);constsetCookie=headers?.['Set-Cookie'];if(setCookie){constnewResponse=newResponse(result.response.body,{status: result.response.status,statusText: result.response.statusText,headers: result.response.headers,});// Append each entry — never `.set()` with an array (comma-joined// Set-Cookie is not a valid single HTTP header).for(constvofArray.isArray(setCookie) ? setCookie : [setCookie]){newResponse.headers.append('Set-Cookie',v);}return{ ...result,response: newResponse};}}returnresult;});};If you skip applying Set-Cookie, refreshed tokens never persist. Next request sees the old expired token → infinite refresh loop.
withAuth() returns a discriminated union. If auth.user exists, all other properties exist:
const{ auth }=awaitauthService.withAuth(request);if(!auth.user){returnredirect('/login');}// TypeScript knows these exist (no ! needed)auth.sessionId;// stringauth.accessToken;// stringauth.claims.sid;// string| Environment Variable | Config Key | Description |
|---|---|---|
WORKOS_CLIENT_ID | clientId | WorkOS client ID |
WORKOS_API_KEY | apiKey | WorkOS API key |
WORKOS_REDIRECT_URI | redirectUri | OAuth callback URL |
WORKOS_COOKIE_PASSWORD | cookiePassword | 32+ char encryption key |
WORKOS_COOKIE_NAME | cookieName | Cookie name (default: wos-session) |
WORKOS_COOKIE_MAX_AGE | cookieMaxAge | Cookie lifetime in seconds |
WORKOS_COOKIE_DOMAIN | cookieDomain | Cookie domain |
WORKOS_COOKIE_SAME_SITE | cookieSameSite | lax, strict, or none |
Environment variables override programmatic config.
// AuthenticationauthService.withAuth(request)// → { auth, refreshedSessionData? }authService.handleCallback(request,response,{ code, state })authService.getSession(request)// → Session | nullauthService.saveSession(response,sessionData)// → { response?, headers? }authService.clearSession(response)// WorkOS OperationsauthService.signOut(sessionId,{ returnTo })// → { logoutUrl, response?, headers? }authService.refreshSession(session,organizationId?)authService.switchOrganization(session,organizationId)// URL Generation — write verifier cookie, return { url, cookieName, response?, headers? }authService.createAuthorization(response,options)authService.createSignIn(response,options)authService.createSignUp(response,options)// Error-path cleanup for the PKCE verifier cookie// `state` is required (from the callback URL) — it identifies which per-flow// verifier cookie to clear. Skip this call when `state` is absent from the// callback URL (malformed callback); the 10-minute PKCE TTL handles the orphan.// (response may be `undefined` for headers-only adapters)authService.clearPendingVerifier(response,{ state,redirectUri? })This library binds every OAuth sign-in to a PKCE code verifier, so a leaked
state value on its own cannot be used to complete a session hijack.
Each in-flight sign-in gets its own per-flow verifier cookie with a deterministic suffix derived from the sealed blob, so concurrent sign-ins from multiple tabs no longer clobber each other.
The verifier is sealed into a single blob that serves two roles:
- It is sent to WorkOS as the OAuth
statequery parameter. - It is set as a short-lived HTTP-only cookie (
wos-auth-verifier-<fnv1a>, 10 min).
The cookie is written and read through SessionStorage. Callers don't see
sealed blobs or cookie options:
// Sign in: library writes the verifier cookie via storage, returns the URL + headersconst{ url, headers }=awaitauthService.createSignIn(response,{returnPathname: '/dashboard',});returnnewResponse(null,{status: 302,headers: { ...headers,Location: url},});// Callback: library reads the verifier via storage, byte-compares, then exchangesawaitauthService.handleCallback(request,response,{
code,
state,// from URL});On success, handleCallback returns a Set-Cookie entry in headers as a
string[] with two values — the session cookie AND a clear for the verifier
cookie. Adapters must append each entry as its own Set-Cookie HTTP header
(never comma-join). The bag key is case-insensitive — mergeHeaderBags
preserves the adapter's casing — so look it up that way:
constsetCookie=result.headers?.['Set-Cookie']??result.headers?.['set-cookie'];if(setCookie){for(constvofArray.isArray(setCookie) ? setCookie : [setCookie]){response.headers.append('Set-Cookie',v);}}Mismatched state and cookie raise OAuthStateMismatchError. A missing cookie
(typical cause: Set-Cookie stripped by a proxy) raises
PKCECookieMissingError. On either error path — or any early bail-out before
handleCallback runs — call
authService.clearPendingVerifier(response, { state }) with the state from
the callback URL to emit a delete header for the correct per-flow cookie:
if(state){awaitauthService.clearPendingVerifier(response,{ state });}If the callback URL has no state (malformed callback), skip this call — the
10-minute PKCE TTL handles the orphan.
For maximum control, use the primitives directly:
import{AuthKitCore,AuthOperations,getConfigurationProvider,getWorkOS,sessionEncryption,}from'@workos/authkit-session';constconfig=getConfigurationProvider().getConfig();constclient=getWorkOS();constcore=newAuthKitCore(config,client,sessionEncryption);constoperations=newAuthOperations(core,client,config,sessionEncryption);// Use core.validateAndRefresh(), core.encryptSession(), etc.- JWKS Caching: Keys fetched on-demand, cached for process lifetime.
josehandles key rotation automatically. - Token Refresh:
validateAndRefreshrefreshes whenverifyTokenfails (i.e. when the access token is expired or invalid).isTokenExpiring(token, buffer)is available as a separate helper for callers that want to proactively refresh before expiry. - Session Encryption: AES-256-CBC + SHA-256 HMAC via
iron-webcrypto. - Lazy Initialization:
createAuthService()defers initialization until first use, allowingconfigure()to be called later.
See @workos/authkit-tanstack-start for a complete example.
MIT