Skip to content

Session Auth

Garret Premo edited this page May 9, 2026 · 3 revisions

Session-Based Authentication

Added in v1.5.0

Many APIs require session-based authentication: you first prove your identity (e.g., with Basic Auth or a login endpoint), receive session cookies, and then use those cookies on every subsequent request. Some APIs also require a CSRF token on mutating requests (POST, PUT, DELETE) to guard against cross-site request forgery.

SessionAuthStrategy wraps any base auth strategy to handle this automatically. It manages the full session lifecycle -- establishing sessions, injecting cookies, mirroring cookie values to custom headers (for CSRF), scoping cookies and headers to specific HTTP methods, and refreshing sessions when they expire.


How It Works

  1. The base auth strategy authenticates first (e.g., BasicAuthStrategy produces an Authorization: Basic ... header).
  2. SessionAuthStrategy sends a request to a configurable session endpoint using those base credentials.
  3. Named cookies are extracted from the Set-Cookie response headers.
  4. On subsequent API requests, extracted cookies are assembled into a Cookie header and sent along.
  5. Optionally, cookie values are mirrored to custom request headers (e.g., an XSRF-TOKEN cookie value copied to an X-XSRF-TOKEN header).
  6. Cookies and mirrored headers can be scoped to specific HTTP methods (e.g., send the CSRF header only on POST/PUT/DELETE).
  7. If a request receives a response with a status code listed in refreshOn, the session is automatically refreshed by re-authenticating and hitting the session endpoint again.

The session endpoint request uses redirect: 'manual' to prevent redirect chains from swallowing Set-Cookie headers.


Configuration

Session auth is configured via the SessionAuthConfig interface:

interface SessionAuthConfig {
  session: {
    endpoint: string;    // Path to the session endpoint, e.g., "/session"
    method?: string;     // HTTP method (default: "GET")
  };
  cookies: {
    extract: string[];   // Cookie names to capture from Set-Cookie headers
    applyTo?: string[];  // HTTP methods to send cookies on; ["*"] for all methods
  };
  headerMirror?: Array<{
    fromCookie: string;  // Cookie name to read
    toHeader: string;    // Header name to write
    applyTo?: string[];  // HTTP methods to apply this header
  }>;
  refreshOn?: number[];  // Status codes that trigger session refresh, e.g., [401]
  dropBaseHeaders?: boolean; // Drop base-strategy headers after handshake (e.g., for Spring Security + 2FA)
}

Field details

session.endpoint -- The path (relative to the base URL) that issues session cookies when called with the base auth credentials. Required.

session.method -- The HTTP method to use when calling the session endpoint. Defaults to "GET".

cookies.extract -- An array of cookie names to capture from the Set-Cookie response headers. Only cookies whose names appear in this array are stored. If this array is empty, a warning is logged at startup.

cookies.applyTo -- An array of HTTP method names (e.g., ["GET", "POST"]) controlling which requests receive the Cookie header. Use ["*"] to send cookies on all requests. If omitted, cookies are sent on all requests.

headerMirror -- An array of rules that copy a cookie value into a custom request header. Each rule has:

  • fromCookie -- the name of the cookie to read from the session
  • toHeader -- the header name to set on the outgoing request
  • applyTo -- (optional) HTTP methods this header applies to; if omitted, the header is sent on all requests where cookies are also sent

refreshOn -- An array of HTTP status codes. When the API client receives one of these status codes, it automatically refreshes the session (re-authenticating with the base strategy and hitting the session endpoint again), then retries the request.

dropBaseHeaders -- When true, drops the base strategy's headers (e.g., Authorization: Basic … from BasicAuthStrategy) from the resolved session after the /session handshake completes. The base headers are still sent to the session endpoint itself; only post-handshake API calls carry just cookies + any headerMirror headers. Defaults to false. See Dropping base-strategy headers post-handshake below for when this is required.


For CLI Authors

Pass a sessionAuth config object alongside your auth strategy in createCli():

import { createCli, BasicAuthStrategy } from "@apijack/core";

const cli = createCli({
  name: "myapi",
  description: "My API CLI",
  version: "1.0.0",
  specPath: "/v3/api-docs",
  auth: new BasicAuthStrategy(),
  sessionAuth: {
    session: { endpoint: "/session" },
    cookies: {
      extract: ["SESSION", "XSRF-TOKEN"],
      applyTo: ["*"],
    },
    headerMirror: [
      {
        fromCookie: "XSRF-TOKEN",
        toHeader: "X-XSRF-TOKEN",
        applyTo: ["POST", "PUT", "DELETE"],
      },
    ],
    refreshOn: [401],
  },
});

await cli.run();

When sessionAuth is present, createCli() automatically wraps the auth strategy in a SessionAuthStrategy. You do not construct SessionAuthStrategy yourself.

Users of your CLI can override session auth settings per-environment by adding a sessionAuth object to their environment config. Their overrides are deep-merged with the defaults you set in createCli() (see Config Merging below).


For Standalone CLI Users

When using the apijack standalone CLI, add a sessionAuth object to the environment in ~/.apijack/config.json:

{
  "active": "dev",
  "environments": {
    "dev": {
      "url": "http://localhost:3459",
      "user": "admin",
      "password": "password",
      "sessionAuth": {
        "session": { "endpoint": "/session", "method": "GET" },
        "cookies": {
          "extract": ["SESSION", "XSRF-TOKEN"],
          "applyTo": ["*"]
        },
        "headerMirror": [
          {
            "fromCookie": "XSRF-TOKEN",
            "toHeader": "X-XSRF-TOKEN",
            "applyTo": ["POST", "PUT", "DELETE"]
          }
        ],
        "refreshOn": [401]
      }
    }
  }
}

The standalone CLI reads sessionAuth from the active environment config, then passes it to createCli() as if it were a CLI author default.


Petstore Example

The Petstore example API demonstrates session auth with CSRF protection end-to-end.

How the Petstore server works

  1. GET /session requires HTTP Basic Auth (admin / password).
  2. On success, the server returns two Set-Cookie headers:
    • SESSION=<token>; Path=/; HttpOnly -- the session cookie
    • XSRF-TOKEN=<token>; Path=/ -- the CSRF token cookie
  3. All authenticated endpoints require the SESSION cookie.
  4. Mutating endpoints (POST, PUT, DELETE) also require an X-XSRF-TOKEN header whose value matches the XSRF-TOKEN cookie.
  5. Sessions expire after 30 minutes.

Configuration

{
  "sessionAuth": {
    "session": { "endpoint": "/session", "method": "GET" },
    "cookies": {
      "extract": ["SESSION", "XSRF-TOKEN"],
      "applyTo": ["*"]
    },
    "headerMirror": [
      {
        "fromCookie": "XSRF-TOKEN",
        "toHeader": "X-XSRF-TOKEN",
        "applyTo": ["POST", "PUT", "DELETE"]
      }
    ],
    "refreshOn": [401]
  }
}

Commands in action

A GET request sends only the SESSION cookie:

apijack pets list

A POST request sends both the SESSION cookie and the X-XSRF-TOKEN header:

apijack pets create-pet --name "Buddy" --species dog --age 3

Use -o curl to inspect the exact headers being sent:

# GET request -- Cookie header only
apijack pets list -o curl
# curl -X GET 'http://localhost:3459/pets' \
#   -H 'Cookie: SESSION=abc123; XSRF-TOKEN=def456'

# POST request -- Cookie header + X-XSRF-TOKEN header
apijack pets create-pet --name "Buddy" --species dog --age 3 -o curl
# curl -X POST 'http://localhost:3459/pets' \
#   -H 'Content-Type: application/json' \
#   -H 'Cookie: SESSION=abc123; XSRF-TOKEN=def456' \
#   -H 'X-XSRF-TOKEN: def456' \
#   -d '{"name":"Buddy","species":"dog","age":3}'

Notice that the X-XSRF-TOKEN header appears only on the POST request, because headerMirror.applyTo is set to ["POST", "PUT", "DELETE"].


Config Merging

When a CLI author provides sessionAuth defaults in createCli() and a user adds sessionAuth overrides in their environment config, the two are deep-merged using these rules:

  • Objects are recursively merged. User values override author defaults for matching keys.
  • Arrays are replaced entirely (not concatenated). If a user specifies cookies.extract, it completely replaces the author's cookies.extract array.
  • Scalars are replaced by the user's value.
  • If no user override exists, the author's default is used as-is.

The merge is performed by deepMergeSessionAuth() from src/auth/config-merge.ts. Neither the author's config nor the user's config is mutated -- a fresh object is returned via structuredClone.

Example

Author defaults:

sessionAuth: {
  session: { endpoint: "/session" },
  cookies: { extract: ["SESSION", "XSRF-TOKEN"], applyTo: ["*"] },
  headerMirror: [
    { fromCookie: "XSRF-TOKEN", toHeader: "X-XSRF-TOKEN", applyTo: ["POST", "PUT", "DELETE"] },
  ],
  refreshOn: [401],
}

User override in environment config:

{
  "sessionAuth": {
    "session": { "endpoint": "/api/session" }
  }
}

Result after merge:

{
  "session": { "endpoint": "/api/session" },
  "cookies": { "extract": ["SESSION", "XSRF-TOKEN"], "applyTo": ["*"] },
  "headerMirror": [
    { "fromCookie": "XSRF-TOKEN", "toHeader": "X-XSRF-TOKEN", "applyTo": ["POST", "PUT", "DELETE"] }
  ],
  "refreshOn": [401]
}

Only session.endpoint changed; everything else was preserved from the author's defaults.


Dropping base-strategy headers post-handshake

Added in v1.13.0

By default, SessionAuthStrategy mirrors the wrapped base strategy's headers (e.g., Authorization: Basic … from BasicAuthStrategy) onto every post-handshake API request alongside the session cookies. For most APIs this is harmless — the cookie-based auth is what the server checks, and the extra header is ignored.

For some stateful backends — Spring Security with 2FA, for instance — re-presenting the base credentials on every call re-triggers the auth filter and either re-prompts, returns 401, or invalidates the active session. Set dropBaseHeaders: true to strip the base strategy's headers after the handshake:

import { createCli, BasicAuthStrategy } from "@apijack/core";

const cli = createCli({
  name: "myapi",
  description: "My API CLI",
  version: "1.0.0",
  specPath: "/v3/api-docs",
  auth: new BasicAuthStrategy(),
  sessionAuth: {
    session: { endpoint: "/session" },
    cookies: { extract: ["SESSION"], applyTo: ["POST", "PUT", "DELETE"] },
    dropBaseHeaders: true,
  },
});

Or in ~/.apijack/config.json for the standalone CLI:

{
  "sessionAuth": {
    "session": { "endpoint": "/session" },
    "cookies": { "extract": ["SESSION"], "applyTo": ["POST", "PUT", "DELETE"] },
    "dropBaseHeaders": true
  }
}

Behavior:

  • Base headers are still sent to the /session endpoint itself — the handshake works as before.
  • Only post-handshake API calls carry just cookies + any headerMirror headers.
  • Drops all headers contributed by the base strategy, not just Authorization. If a custom base strategy contributes non-auth headers you need to keep, write a custom AuthStrategy instead of using this flag.
  • Default is false, which preserves backwards compatibility with existing configs.

Troubleshooting

Session endpoint returns a redirect

Some servers redirect the session/login endpoint (e.g., 302 Found). SessionAuthStrategy uses redirect: 'manual' when calling the session endpoint, which means redirects are not followed. This is intentional -- following redirects can cause Set-Cookie headers from the original response to be lost.

If your session endpoint returns a 3xx status, SessionAuthStrategy will throw an error because the response is not 2xx. You may need to adjust the endpoint path or server configuration so it returns 200 directly.

Empty cookies.extract array

If cookies.extract is an empty array, SessionAuthStrategy logs a warning at construction time:

SessionAuthStrategy: cookies.extract is empty -- no cookies will be captured

This is almost always a configuration mistake. Make sure you list every cookie name the server sets that you need for authentication.

Missing CSRF header on mutating requests

If POST/PUT/DELETE requests fail with a 403 Forbidden or similar CSRF error, check:

  1. headerMirror is configured -- Verify you have a headerMirror entry that maps the CSRF cookie to the expected header.
  2. headerMirror.applyTo matches the HTTP method -- If applyTo is set to ["POST", "PUT"] but you're making a DELETE request, the header won't be sent on DELETE. Add the missing method.
  3. Cookie name matches exactly -- The fromCookie value must match the exact cookie name set by the server (case-sensitive).
  4. cookies.applyTo allows the method -- The mirrored header is only sent when cookies are also being sent. If cookies.applyTo excludes the method, neither cookies nor mirrored headers will be applied.

Session expired but no automatic refresh

Make sure refreshOn includes the status code your server returns for expired sessions (commonly 401). Without this, the client will surface the error response instead of retrying with a fresh session.


Related

Clone this wiki locally