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 run | Use |
|---|---|
| Koa | @loginwith/koa — middleware, whitelist, typed principal, M2M minter |
| Another Node / edge server | @loginwith/sdk — makeVerifier, plus a typed API client |
| Anything else | Any 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>.
| Option | Required | Default | |
|---|---|---|---|
issuer | yes | iss exactly as your tokens carry it: https://acme.loginwith.dev/, trailing slash included. | |
clientId | yes | The 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 configuredissuer, never from the token. The keys are cached byjosefor about ten minutes. issequalsissuer, byte for byte.expis in the future.typ: "user"—audincludesclientId,substarts withusr_,tntstarts withtnt_.typ: "client"(only if accepted) —audequals the token’s ownsub, andsubis intrustedClients.- Anything else —
console-usertokens, an unknowntyp, a malformed claim — isnull. Every failure isnull, 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.verify | A VerifyToken — usually LoginWithVerifier(...). Any function of that shape works. |
deps.realm | The realm in the WWW-Authenticate challenge. A constant from config. |
allow | A whitelist(...). Default whitelist([]): nothing passes without a token. |
For each request:
- If
allow(ctx.path, ctx.method)is true, it callsnext()without looking for a token. - It reads
Authorization. The schemeBeareris case-insensitive. - It calls
verify(token). A result goes onctx.state.loginwith, thennext(). - 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"]]opensGET /health, notDELETE /health. Verbs:GET,HEAD,POST,PUT,PATCH,DELETE,OPTIONS. - A string matches exactly.
"/health"does not cover/health-internalor/health/. Matched againstctx.path, so the query string is ignored. - Anything looser is a
RegExp, tested againstctx.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 | |
|---|---|
issuer | iss exactly, trailing slash included. |
clientId | The 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)
| Option | Required | |
|---|---|---|
api | yes | Where requests go — https://acme.loginwith.dev. |
issuer | yes | iss exactly, trailing slash included. |
clientId | yes | Your client. Sent as client_id on token requests, and the aud verify checks. |
hostOverride | Development only: forces the org via ?host= against a localhost issuer. | |
switchTenantGrant | The tenant-switch grant URN. Default urn:loginwith:params:oauth:grant-type:switch-tenant. |
| Member | Returns | |
|---|---|---|
jwksUri | URL | {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 | |
|---|---|
status | The HTTP status. |
code | The server’s error, when it answered JSON. |
detail | The 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:
| Keys | https://acme.loginwith.dev/.well-known/jwks.json — pick the key by the header’s kid. Discovery: https://acme.loginwith.dev/.well-known/openid-configuration. |
alg | RS256. Refuse anything else. |
iss | https://acme.loginwith.dev/ — your org’s slug, trailing slash included. Compare exactly. |
aud | Your client id, cli_…. |
exp | In the future. |
typ | user 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 | |
|---|---|
sub | The user id, usr_…. Never the email. |
tnt | The tenant the session acts in, tnt_…. |
sid | The sign-in session. |
jti | This token’s id. |
amr, acr | How the user authenticated, and the assurance level reached. |
act | Present only on an impersonated session: { sub } of the admin acting as the user. |
azp | Present 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.