Server & API SDKs

Verify LoginWith tokens in your API — @loginwith/koa, @loginwith/sdk, or any JOSE library.

Your API receives Authorization: Bearer <token> and has to answer one question: did LoginWith mint this, for my app, and who is it? Pick by stack:

You runUse
Koa@loginwith/koa — middleware, whitelist, typed principal, M2M minter
Another Node / edge server@loginwith/sdk — makeVerifier, plus a typed API client
Anything elseAny JOSE library — it is a standard RS256 JWT

Koa

npm add @loginwith/koa
import Koa from "koa"
import { authenticate, LoginWithVerifier, whitelist, principalOf } from "@loginwith/koa"

const app = new Koa()

app.use(authenticate(
  {
    verify: LoginWithVerifier({
      issuer: "https://acme.loginwith.dev/",   // trailing slash
      clientId: "cli_…",
    }),
    realm: "acme-api",
  },
  whitelist([["/health", ["GET"]]]),
))

app.use((ctx) => {
  const actor = principalOf(ctx)
  if (actor.kind !== "user") ctx.throw(403)
  ctx.body = { userId: actor.userId, tenantId: actor.tenantId }
})

LoginWithVerifier(options)

Returns a VerifyToken — (token) => Promise<VerifiedToken | null>.

OptionRequiredDefault
issueryesiss exactly as your tokens carry it: https://acme.loginwith.dev/, trailing slash included.
clientIdyesThe aud a user token must carry — the client the person signed in to.
accept["user"]Token types to accept: "user", "client", or both.
trustedClients[clientId]Client ids whose machine tokens are accepted. Only read when accept includes "client".

What it checks:

  • Signature, RS256 only, against {issuer}.well-known/jwks.json. The key set URL comes from your configured issuer, never from the token. The keys are cached by jose for about ten minutes.
  • iss equals issuer, byte for byte. exp is in the future.
  • typ: "user" — aud includes clientId, sub starts with usr_, tnt starts with tnt_.
  • typ: "client" (only if accepted) — aud equals the token’s own sub, and sub is in trustedClients.
  • Anything else — console-user tokens, an unknown typ, a malformed claim — is null. Every failure is null, with no reason.

authenticate(deps, allow?)

The middleware. Put it before your routes — and after any CORS middleware, so preflight OPTIONS requests are answered before they need a token.

Argument
deps.verifyA VerifyToken — usually LoginWithVerifier(...). Any function of that shape works.
deps.realmThe realm in the WWW-Authenticate challenge. A constant from config.
allowA whitelist(...). Default whitelist([]): nothing passes without a token.

For each request:

  1. If allow(ctx.path, ctx.method) is true, it calls next() without looking for a token.
  2. It reads Authorization. The scheme Bearer is case-insensitive.
  3. It calls verify(token). A result goes on ctx.state.loginwith, then next().
  4. Otherwise — no header, wrong scheme, expired, forged, wrong audience — the same 401:
HTTP/1.1 401 Unauthorized
WWW-Authenticate: Bearer realm="acme-api"
Content-Type: application/json

{"error":"unauthorized","error_detail":"we don't know who you are"}

The 401 is set on ctx, not thrown, so the WWW-Authenticate header survives Koa’s error handler. Unknown paths sit behind it too: without a token, a path that does not exist answers 401, not 404.

whitelist(rules)

whitelist([
  ["/health", ["GET", "HEAD"]],
  [/^\/public\//, ["GET"]],
])

Each rule is [pattern, verbs].

  • Per verb, not per path. ["/health", ["GET"]] opens GET /health, not DELETE /health. Verbs: GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS.
  • A string matches exactly. "/health" does not cover /health-internal or /health/. Matched against ctx.path, so the query string is ignored.
  • Anything looser is a RegExp, tested against ctx.path. Anchor it.

principalOf(ctx) and ctx.state.loginwith

principalOf(ctx) returns ctx.state.loginwith, typed. It throws if authenticate() did not run for this request — including on a whitelisted route, where no token was read.

type VerifiedToken =
  | { kind: "user";   userId: string; tenantId: string; claims: Record<string, unknown> }
  | { kind: "client"; clientId: string;                 claims: Record<string, unknown> }

Narrow on kind. A route that serves people should refuse a machine, and the type makes that a choice you write down. claims is the full verified payload (sid, amr, acr, act, …). Mapping tenantId to your own records is your code’s job.

Machine tokens

A service calling your API acts as itself, with no user. It is a confidential client and mints a token with the client-credentials grant; that token has typ: "client" and aud equal to its own client id.

Accept them by opting in and naming the callers you trust:

LoginWithVerifier({
  issuer: "https://acme.loginwith.dev/",
  clientId: "cli_web",
  accept: ["user", "client"],
  trustedClients: ["cli_billing", "cli_reports"],
})

A trusted caller arrives as { kind: "client", clientId: "cli_billing" }.

Machine-to-machine

Mint them with createTokenMinter when your service calls another one:

import { createTokenMinter } from "@loginwith/koa"

const minter = createTokenMinter({
  api: "https://acme.loginwith.dev",
  issuer: "https://acme.loginwith.dev/",
  clientId: process.env.LGW_CLIENT_ID!,         // a confidential client
  clientSecret: process.env.LGW_CLIENT_SECRET!,
})

const res = await fetch("https://billing.acme.internal/invoices", {
  headers: { authorization: `Bearer ${await minter.token()}` },
})
Method
token()A live token. Mints on first call and when the cached one is within 10 seconds of exp. Callers arriving together share one mint.
fresh()Drops the cache and mints a new one. Call it once when the downstream answers 401.

The secret is sent only in the /oauth/token request body (client_secret_post). Keep the minter server-side.

@loginwith/sdk

npm add @loginwith/sdk

The raw, typed API client — isomorphic (global fetch and jose), no framework, and token-agnostic: every call takes the bearer it should use. The SvelteKit and Koa packages are built on it.

Verify a token, without Koa

import { makeVerifier } from "@loginwith/sdk"

const verify = makeVerifier({
  issuer: "https://acme.loginwith.dev/",
  clientId: "cli_…",
  jwksUri: "https://acme.loginwith.dev/.well-known/jwks.json",
})

export async function userFrom(authorization: string | undefined) {
  const [scheme, token] = (authorization ?? "").split(" ")
  if (scheme?.toLowerCase() !== "bearer" || !token) return null
  try {
    const claims = await verify(token)
    if (claims.typ !== "user") return null
    return { userId: claims.sub as string, tenantId: claims.tnt as string }
  } catch {
    return null
  }
}

makeVerifier checks the RS256 signature against jwksUri, iss and aud (against clientId) and exp, and returns the payload. It throws on any failure. It does not check typ — do it yourself: a person is typ: "user", a machine is typ: "client".

Option
issueriss exactly, trailing slash included.
clientIdThe aud the token must carry.
jwksUri{api}/.well-known/jwks.json.

createSdk(config)

import { createSdk } from "@loginwith/sdk"

const sdk = createSdk({
  api: "https://acme.loginwith.dev",
  issuer: "https://acme.loginwith.dev/",
  clientId: "cli_…",
})

const me = await sdk.users.me(accessToken)
const tenants = await sdk.tenants.list(accessToken)
OptionRequired
apiyesWhere requests go — https://acme.loginwith.dev.
issueryesiss exactly, trailing slash included.
clientIdyesYour client. Sent as client_id on token requests, and the aud verify checks.
hostOverrideDevelopment only: forces the org via ?host= against a localhost issuer.
switchTenantGrantThe tenant-switch grant URN. Default urn:loginwith:params:oauth:grant-type:switch-tenant.
MemberReturns
jwksUriURL{api}/.well-known/jwks.json.
verify(token)Promise<JWTPayload>makeVerifier bound to this config. Throws on failure.
token.authorizationCode({ code, redirectUri, codeVerifier?, clientSecret? })Promise<TokenSet>Redeem a code.
token.refresh({ refreshToken, clientSecret? })Promise<TokenSet>Spend a refresh token. It is rotated — keep the new one.
token.switchTenant({ accessToken, tenantId })Promise<TokenSet>Move a session to another tenant of the org.
token.clientCredentials({ clientId, clientSecret })Promise<TokenSet>A machine token for a confidential client.
revoke({ token })Promise<void>End a session (RFC 7009).
users.me(token)Promise<User>{ email, firstName, lastName, picture, provider }, from /users/me.
tenants.list(token)Promise<Tenant[]>{ id, name, role, logo }[].
tenants.create(token, name)Promise<Tenant>
tenants.update(token, id, { name?, logo? })Promise<Tenant>
tenants.delete(token, id)Promise<void>
organizations.get(token, slug)Promise<Organization | null>{ id, slug, role }, or null when not a member.
profile.session(token)Promise<{ key }>A short-lived key for the hosted profile page.

TokenSet is { accessToken, tokenType, expiresIn?, refreshToken? }. clientSecret is sent in the request body (client_secret_post).

SdkError

Any non-2xx response throws an SdkError:

Field
statusThe HTTP status.
codeThe server’s error, when it answered JSON.
detailThe server’s error_detail, when it answered JSON.

It never carries a token, a code or a secret.

import { SdkError } from "@loginwith/sdk"

try {
  await sdk.tenants.create(token, "Acme EU")
} catch (e) {
  if (e instanceof SdkError && e.status === 401) { /* re-authenticate */ }
  throw e
}

Any other language

An access token is an RS256 JWT. Verify it with any JOSE library:

Keyshttps://acme.loginwith.dev/.well-known/jwks.json — pick the key by the header’s kid. Discovery: https://acme.loginwith.dev/.well-known/openid-configuration.
algRS256. Refuse anything else.
isshttps://acme.loginwith.dev/ — your org’s slug, trailing slash included. Compare exactly.
audYour client id, cli_….
expIn the future.
typuser for a person. Refuse other values unless you mean to accept them.

Pin the issuer and the key set URL in your config. Never take them from the token’s own iss.

Claims on a user token:

Claim
subThe user id, usr_…. Never the email.
tntThe tenant the session acts in, tnt_….
sidThe sign-in session.
jtiThis token’s id.
amr, acrHow the user authenticated, and the assurance level reached.
actPresent only on an impersonated session: { sub } of the admin acting as the user.
azpPresent only on a promoted token: the public client it came from.

A machine token has typ: "client", sub and aud both equal to the calling client’s id, jti, and no tnt, sid, amr or acr. Accept one only from a client id you list.