Stackure JavaScript SDK
Protect routes in Express, Connect or plain Node.js. Zero dependencies.
Install the package. Set the app ID and secret. Add the middleware. For how sign-in works, see the overview. For the HTTP calls the SDK makes, see the API reference.
Install
npm install stackure
It needs Node.js 22 or later. It's ESM only and ships its TypeScript types. See the package and the source.
Configure
export STACKURE_APP_ID=...
export STACKURE_APP_SECRET=...
export STACKURE_BASE_URL=https://stackure.com
The SDK reads all 3 from the environment at call time and never caches them, so no function takes them as arguments.
STACKURE_APP_IDis the app ID from the app's page in Stackure. When it's unset or isn't a UUID,sendMagicLink,validateSession,directoryUsersanddirectoryTeamsthrow avalidationerror,verifyreturns a 500 result,auth()answers 500 andmcp()answers 503.logoutdoesn't need it.STACKURE_APP_SECRETis shown once, when you register the app and each time you rotate its secret. It's sent asX-App-Secreton every call to Stackure except sign-out, which carries only the session token. When it's unset, any call that needs it fails with avalidationerror before it's sent. After a rotation, checks fail until you update it.STACKURE_BASE_URLis the Stackure address and defaults tohttps://stackure.com. Each region has its own address. This page is for https://stackure.com. The SDK sends every call there and accepts the sign-in handoff only from there.
Protect a route
import { auth, userFromRequest } from 'stackure';
app.get('/admin', auth(), (req, res) => {
const user = userFromRequest(req);
res.json({ email: user.user_email, account: user.account_id });
});
userFromRequest(req) returns the signed-in user, or undefined outside auth() and mcp(). A request without a valid session doesn't reach your handler:
- A browser request, one whose
Acceptheader includestext/htmlbut notapplication/json, is redirected to Stackure sign-in. - Any other request gets a
401with this body:
{"error":"Unauthorized","message":"Valid authentication required","sign_in_url":"https://stackure.com/sign-in/magic-link?app_id=YOUR_APP_ID"}
When the check can't be completed, for example because Stackure can't be reached or the app secret is wrong, the request gets a 500 with "message":"Authentication verification failed" and no redirect. The SDK logs the cause with console.error, starting stackure: verification error:.
Every request with a session cookie is checked with Stackure, so signing out or losing access takes effect on the next request. A request without a well-formed token gets the sign-in URL without a call to Stackure. Sessions aren't tied to the browser's user agent or IP address. The SDK forwards User-Agent and X-Forwarded-For, but Stackure doesn't use them to check the session.
The middleware writes with res.setHeader and res.writeHead, so it works on Express, Connect and a bare http.createServer. On Fastify, pass request.raw and reply.raw.
Sign-in handoff
You don't write this part. After the user signs in, Stackure sends the browser to your app's registered URL with a form POST carrying session_token, a token that works only for your app. auth() checks that the POST came from STACKURE_BASE_URL, validates the token with Stackure, stores it in a cookie on your domain and redirects to the same URL with a 303, so the browser loads it with a GET.
- Run
auth()for POST requests at the registered URL, for example withapp.use(auth()), or the handoff never reaches it.app.getalone isn't enough. - If a body parser runs before
auth()and setsreq.body, the token is read from there, so that parser must handle URL-encoded forms, asexpress.urlencoded()does. - The cookie is named
session. It'sHttpOnly,SameSite=LaxandPath=/. It'sSecurewhen the request arrived over HTTPS or withX-Forwarded-Proto: https. It lasts 7 days. - The session ends 1 day after the user signed in to Stackure, or sooner when they sign out or sign in again.
- Handoff bodies over 4,096 bytes and tokens that don't check out are ignored.
- Someone already signed in to Stackure is handed in without signing in again.
People can open the app once an app admin gives it to everyone in the organization or to one of their teams. App admins and the owner can always open it. Anyone else who signs in sees "You don't have access to this app."
Protect an MCP endpoint
import { mcp, userFromRequest } from 'stackure';
app.all('/mcp', mcp(), (req, res) => {
const user = userFromRequest(req);
// serve the MCP request
});
AI clients such as Claude, Claude Code, VS Code and Cursor sign users in through Stackure and send the credential it issues as a bearer token. mcp() checks every request with Stackure using the same app ID and secret. There's no extra setup.
- The user is attached as with
auth(), souserFromRequestworks in your MCP handler. - A request that isn't signed in gets a
401with{"error":"unauthorized"}and aWWW-Authenticateheader that tells the AI client where to sign in. - A check that can't be completed gets a
503with{"error":"unavailable"}. The SDK logs the cause, startingstackure: mcp verification error:. mcp()reads onlyAuthorization: Bearer. It never redirects and never reads or sets a cookie, so mount it on the MCP route alone, not behindauth().- The endpoint must use HTTPS on the same host and port as the app's registered URL, unless an MCP URL is set for the app in Stackure. The SDK works out the endpoint's address from the request's scheme,
Hostheader and path, so behind a proxy pass the originalHostandX-Forwarded-Protothrough. - Access ends when the user signs out, disconnects the client or loses access to the app. A connection also ends after 30 days unused or 90 days after it was made.
The signed-in user
auth() and mcp() attach the same User:
| Field | Value |
|---|---|
user_id | The user's ID |
account_id | The ID of the user's organization |
user_email | Their email address |
user_first_name | Their first name |
user_last_name | Their last name |
user_is_app_admin | Whether they're an app admin or the owner, in charge of the organization's apps |
user_teams | Their teams, each { team_id, team_name }. Empty when there are none. |
Stackure defines no permissions inside your app. Your app decides what these facts mean.
List who can open the app
import { directoryTeams, directoryUsers } from 'stackure';
const { items, nextCursor, total } = await directoryUsers(req, { query: 'ada' });
// items: { user_id, user_email, user_first_name, user_last_name }[]
const teams = await directoryTeams(req, { limit: 50 });
// teams.items: { team_id, team_name }[]
For pickers and sharing, directoryUsers lists the users in the caller's organization who can open your app and directoryTeams the teams that have it. Each call returns one page, sorted by name.
querymatches a user's name, email and job title, or a team's name.limitis the page size: 100 by default, 200 at most.idsreturns only those IDs that can open the app, up to 200, to check or name people and teams you already store.- Pass
nextCursorascursorfor the next page. It's absent on the last one. totalis set on the first page when there are 10,000 matches or fewer.- Both use the request's session cookie, so call them from a route behind
auth(). MCP bearer tokens aren't accepted. Without a valid session, they throw anautherror.
Verify without middleware
import { verify } from 'stackure';
const result = await verify(req);
if (!result.authenticated) {
// result.error.code, result.error.message, result.error.sign_in_url
return res.status(result.error.code).json(result.error);
}
// result.user
verify never throws. No valid session is a result with code 401 and a sign_in_url. A failed check is a result with code 500. The SDK logs the cause. verify doesn't take the sign-in handoff, so the registered URL still needs auth().
validateSession(req) returns the raw result instead, { authenticated, user, sign_in_url }, and throws StackureError when the check fails.
Send a sign-in link
import { sendMagicLink } from 'stackure';
const resp = await sendMagicLink('user@example.com');
// resp.message
Stackure emails the user a sign-in link for your app. It works for 15 minutes. Opening it asks the user to confirm with Continue on Stackure. It then signs them in and, if they can open the app, sends them to it. The response is the same whether or not the address belongs to someone. A malformed address is a validation error before any call. Sending is limited to 3 requests per minute and 5 requests per hour for each email and IP address. Past that, it throws a network error whose statusCode is 429.
Sign out
import { logout } from 'stackure';
app.all('/logout', (req, res) => logout(req, res));
<form method="post" action="/logout"><button>Sign Out</button></form>
- Mount it for every method on the sign-out path, as
app.alldoes, so every request to it reaches the SDK. - Trigger it with a form or button that POSTs from your app's own page. That POST signs the user out of Stackure everywhere, on every app, device and AI client. It clears your cookie and redirects to Stackure. If the call fails, the redirect goes to the Stackure sign-out page, where the user can finish signing out.
- A link or any other request goes to the Stackure sign-out page, where the user confirms. Nothing is called and the cookie stays.
- A POST counts as from your own page when
Sec-Fetch-Siteissame-originor, without that header, whenOriginmatches theHostheader and useshttpsif the request arrived over HTTPS. Requests that sendSec-Fetch-Site,OriginorHostmore than once don't count. This keeps a link on another site from signing your users out. logoutreturns aPromise<void>and never throws.
Errors
sendMagicLink, validateSession, directoryUsers and directoryTeams throw StackureError, with code, statusCode (undefined when no response arrived) and message. Switch on code:
import { StackureError } from 'stackure';
try {
await sendMagicLink(email);
} catch (err) {
if (err instanceof StackureError) {
switch (err.code) {
case 'validation':
case 'auth':
case 'forbidden':
case 'timeout':
case 'network':
// ...
}
}
}
| Code | Meaning |
|---|---|
validation | A missing or malformed email or STACKURE_APP_ID. An unset STACKURE_APP_SECRET. Caught before any request. |
auth | Stackure answered 401, for example for a wrong app secret. directoryUsers and directoryTeams also throw it without a valid session. |
forbidden | Stackure answered 403. |
timeout | The call ran past its 2-second deadline. |
network | Everything else: no connection, a 429, another 4xx, a 5xx or a response that isn't valid JSON. |
auth(), mcp(), verify and logout don't throw. auth() and verify report a failed check as 500 and mcp() as 503. logout always redirects.
Timeouts and limits
- Every call has one 2-second deadline covering the connection, headers, body and retry.
- A 5xx response or a connection failure is retried once after 500 ms, only when more than 500 ms of the deadline is left.
- Timeouts are never retried. They're
timeouterrors. - Stackure accepts 1,000 requests per minute per app for session checks, as many again for MCP checks and as many again for
directoryUsersanddirectoryTeamstogether. Past that it answers 429, whichauth()andverifyturn into a 500,mcp()into a 503 anddirectoryinto anetworkerror.
Releases
Releases are published to npm from main automatically and tagged v1.YYYYMMDD.N. Each one carries npm provenance and a GitHub build-provenance attestation. The SDK is MIT licensed. Report a vulnerability through GitHub Security Advisories.