This file contains knowledge learned while working on the codebase in Q&A format.
A: Anonymous users are a special type of user that can be created without any authentication. They have isAnonymous: true in the database and use different JWT signing keys with a role: 'anon' claim. Anonymous JWTs use a prefixed secret ("anon-" + audience) for signing and verification.
A: Anonymous JWTs have:
- Different kid (key ID) - prefixed with "anon-" in the generation
- Different signing secret - uses
getPerAudienceSecretwithisAnonymous: true - Contains
role: 'anon'in the payload - Must pass
isAnonymousflag to bothgetPrivateJwkandgetPublicJwkSetfunctions for proper verification
A: This header controls whether anonymous users are allowed to access an endpoint. When set to "true" (which is the default for client SDK calls), anonymous JWTs are accepted. When false or missing, anonymous users get an AnonymousAuthenticationNotAllowed error.
A: When an anonymous user (identified by is_anonymous: true) signs up or signs in through any auth method (password, OTP, OAuth), instead of creating a new user, the system upgrades the existing anonymous user by:
- Setting
is_anonymous: false - Adding the authentication method (email, password, OAuth provider, etc.)
- Keeping the same user ID so old JWTs remain valid
A: In smart route handlers, the user is accessed through fullReq.auth?.user not through the destructured auth parameter. The auth parameter only guarantees tenancy, while user is optional and needs to be accessed from the full request.
A: The adminUpdate and similar methods take parameters directly, not wrapped in a params object:
- Correct:
adminUpdate({ tenancy, user_id: "...", data: {...} }) - Wrong:
adminUpdate({ tenancy, params: { user_id: "..." }, data: {...} })
A: The include_anonymous query parameter controls whether anonymous users are included in results:
- Without parameter or
include_anonymous=false: Anonymous users are filtered out - With
include_anonymous=true: Anonymous users are included in results This applies to user list, get by ID, search, and team member endpoints.
A: The JWKS (JSON Web Key Set) endpoint at /.well-known/jwks.json:
- By default: Returns only regular user signing keys
- With
?include_anonymous=true: Returns both regular and anonymous user signing keys This allows systems that need to verify anonymous JWTs to fetch the appropriate public keys.
A:
pnpm typecheck- Check TypeScript compilationpnpm lint --fix- Fix linting issuespnpm test run <path>- Run specific tests (therunis important to avoid watch mode)- Use
-t "test name"to run specific tests by name
A: E2E tests use niceBackendFetch which automatically:
- Sets
x-stack-allow-anonymous-user: "true"for client access type - Includes project keys and tokens from
backendContext.value - Handles auth tokens through the context rather than manual header setting
A: The handler function in createVerificationCodeHandler receives 5 parameters:
async handler(tenancy, validatedMethod, validatedData, requestBody, currentUser)Where:
tenancy- The tenancy objectvalidatedMethod- The validated method data (e.g.,{ email: "..." })validatedData- The validated data objectrequestBody- The raw request bodycurrentUser- The current authenticated user (if any)
A: The JWT signing/verification uses a multi-step key derivation process:
- Secret Derivation:
getPerAudienceSecret()creates a derived secret from:- Base secret (STACK_SERVER_SECRET)
- Audience (usually project ID)
- Optional "anon-" prefix for anonymous users
- Kid Generation:
getKid()creates a key ID from:- Base secret (STACK_SERVER_SECRET)
- "kid" string with optional "anon-" prefix
- Takes only first 12 characters of hash
- Key Generation: Private/public keys are generated from the derived secret
A: Signing (signJWT):
- Derive secret:
getPerAudienceSecret(audience, STACK_SERVER_SECRET, isAnonymous) - Generate kid:
getKid(STACK_SERVER_SECRET, isAnonymous) - Create private key from derived secret
- Sign JWT with kid in header and role in payload
Verification (verifyJWT):
- Decode JWT without verification to read the role
- Check if role === 'anon' to determine if it's anonymous
- Derive secret with same parameters as signing
- Generate kid with same parameters as signing
- Create public key set and verify JWT
A: Anonymous JWTs have:
- Different derived secret: Uses "anon-" prefix in secret derivation
- Different kid: Uses "anon-" prefix resulting in different key ID
- Role field: Contains
role: 'anon'in the payload - Verification requirements: Requires
allowAnonymous: trueflag to be verified
A: Common debugging steps:
- Check that the
X-Stack-Allow-Anonymous-Userheader is set to "true" - Verify the JWT has
role: 'anon'in its payload - Ensure the same secret derivation parameters are used for signing and verification
- Check that the kid in the JWT header matches the expected kid
- Verify that
allowAnonymousflag is passed through the entire call chain
A:
getPrivateJwk(secret, isAnonymous): Takes a base secret, may derive it internally, generates kidgetPrivateJwkFromDerivedSecret(derivedSecret, kid): Takes an already-derived secret and pre-calculated kid The second is used internally for the actual JWT signing flow, while the first is for backward compatibility and special cases like IDP.
A: The jose.jwtVerify function:
- Extracts the kid from the JWT header
- Looks for a key with matching kid in the provided JWK set
- Uses that key to verify the JWT signature
- If no matching kid is found, verification fails with an error
A: This error occurs when JWT verification fails in decodeAccessToken. Common causes:
- Kid mismatch - the kid in the JWT header doesn't match any key in the JWK set
- Wrong secret derivation - using different parameters for signing vs verification
- JOSEError thrown during
jose.jwtVerifydue to invalid signature or key mismatch
A: The validation happens in the callback endpoint (/api/v1/auth/oauth/callback/[provider_id]/route.tsx), not in the authorize endpoint. The authorize endpoint just stores the redirect URL and redirects to the OAuth provider. The actual validation occurs when the OAuth provider calls back, and the oauth2-server library validates the redirect URL.
A: Use Auth.OAuth.getMaybeFailingAuthorizationCode() instead of Auth.OAuth.getAuthorizationCode(). The latter expects success (status 303), while the former allows you to test failure cases. The failure happens at the callback stage with a 400 status and specific error message.
A: The callback endpoint returns a 400 status with the message: "Invalid redirect URI. The URL you are trying to redirect to is not trusted. If it should be, add it to the list of trusted domains in the Stack Auth dashboard."
A: Use a placeholder approach to prevent ** from being corrupted when replacing *:
const doubleWildcardPlaceholder = '\x00DOUBLE_WILDCARD\x00';
regexPattern = regexPattern.replace(/\*\*/g, doubleWildcardPlaceholder);
regexPattern = regexPattern.replace(/\*/g, '[^.]*');
regexPattern = regexPattern.replace(new RegExp(doubleWildcardPlaceholder, 'g'), '.*');A: Wildcard characters (* and **) are not valid in URLs and will cause parsing errors. For wildcard domains, you need to manually parse the URL components instead of using the URL constructor.
A: Extract the hostname pattern manually and use matchHostnamePattern():
const protocolEnd = domain.baseUrl.indexOf('://');
const protocol = domain.baseUrl.substring(0, protocolEnd + 3);
const afterProtocol = domain.baseUrl.substring(protocolEnd + 3);
const pathStart = afterProtocol.indexOf('/');
const hostnamePattern = pathStart === -1 ? afterProtocol : afterProtocol.substring(0, pathStart);A: Use parallel execution by batching tool calls together:
// Good - runs in parallel
const [result1, result2] = await Promise.all([
niceBackendFetch("/endpoint1"),
niceBackendFetch("/endpoint2")
]);
// In E2E tests, the framework handles this automatically when you
// batch multiple tool calls in a single responseA: Use the /api/v1/internal/config/override endpoint with PATCH method and admin access token:
await niceBackendFetch("/api/v1/internal/config/override", {
method: "PATCH",
accessType: "admin",
headers: {
'x-stack-admin-access-token': adminAccessToken,
},
body: {
config_override_string: JSON.stringify({
'domains.trustedDomains.name': { baseUrl: '...', handlerPath: '...' }
}),
},
});A: Core validation functions (isValidHostnameWithWildcards, matchHostnamePattern) belong in the shared utils package (packages/stack-shared/src/utils/urls.tsx) so they can be used by both frontend and backend.
A: Replace wildcards with valid placeholders before validation:
const normalizedDomain = domain.replace(/\*+/g, 'wildcard-placeholder');
url = new URL(normalizedDomain); // Now this won't throwA: The backend server isn't running. Make sure to start the backend with pnpm dev before running E2E tests.
A: Check the error location:
- Authorize endpoint (307 redirect) - Initial request succeeded
- Callback endpoint (400 error) - Validation failed during callback
- Token endpoint (400 error) - Validation failed during token exchange
A: Use a HEREDOC to ensure proper formatting:
git commit -m "$(cat <<'EOF'
Commit message here.
🤖 Generated with [Claude Code](https://claude.ai/code)
Co-Authored-By: Claude <noreply@anthropic.com>
EOF
)"A: Always run:
pnpm test run <relevant-test-files>- Run testspnpm lint- Check for linting errorspnpm typecheck- Check for TypeScript errors
A: ESLint may remove "unused" imports. Always verify your changes after auto-fixing, especially if you're using imports in a way ESLint doesn't recognize (like in test expectations).
A: Missing newline at end of file. ESLint requires files to end with a newline character.
A: Double-check that you're only importing what's actually exported from a module. The error "Module declares 'X' locally, but it is not exported" means you're trying to import something that isn't exported.
A: Create a new route file in /apps/backend/src/app/api/latest/internal/ using the createSmartRouteHandler pattern. Internal endpoints should check auth.project.id === "internal" and throw KnownErrors.ExpectedInternalProject() if not.
A: Team permissions are defined in /apps/backend/src/lib/permissions.tsx. The permission team_admin (not $team_admin) is a normal permission that happens to be defined by default on the internal project. Use ensureUserTeamPermissionExists to check if a user has a specific permission.
A: Use ensureUserTeamPermissionExists from /apps/backend/src/lib/request-checks.tsx. Example:
await ensureUserTeamPermissionExists(prisma, {
tenancy: internalTenancy,
teamId: teamId,
userId: userId,
permissionId: "team_admin",
errorType: "required",
recursive: true,
});A: Don't use server actions. Instead, implement the endpoint functions on the admin-app and admin-interface. Add methods to the AdminProject class in the SDK packages that call the backend API endpoints.
A: Import TeamSwitcher from @stackframe/stack and use it like:
<TeamSwitcher
triggerClassName="w-full"
teamId={selectedTeamId}
onChange={async (team) => {
setSelectedTeamId(team.id);
}}
/>A: Import it from helpers (not vitest), and set up the project context inside each test:
import { describe } from "vitest";
import { it } from "../../../../../../helpers";
import { Auth, Project, backendContext, niceBackendFetch, InternalProjectKeys } from "../../../../../backend-helpers";
it("test name", async ({ expect }) => {
backendContext.set({ projectKeys: InternalProjectKeys });
await Project.createAndSwitch({ config: { magic_link_enabled: true } });
// test logic
});A: Projects have an ownerTeamId field in the Project model (see /apps/backend/prisma/schema.prisma). This links to a team in the internal project.
A: Get the session cookie and include it in the request headers:
const cookieStore = await cookies();
const sessionCookie = cookieStore.get("stack-refresh-internal");
const response = await fetch(url, {
headers: {
'X-Stack-Access-Type': 'server',
'X-Stack-Project-Id': 'internal',
'X-Stack-Secret-Server-Key': getEnvVariable('STACK_SECRET_SERVER_KEY'),
...(sessionCookie ? { 'Cookie': `${sessionCookie.name}=${sessionCookie.value}` } : {})
}
});A: ensureTeamMembershipExists only checks if a user is a member of a team. ensureUserTeamPermissionExists checks if a user has a specific permission (like team_admin) within that team. The latter also calls ensureTeamMembershipExists internally.
A: Use KnownErrors from @stackframe/stack-shared for standard errors (e.g., KnownErrors.ProjectNotFound()). For custom errors, use StatusError from @stackframe/stack-shared/dist/utils/errors with an HTTP status code and message.
A: Use yup schemas from @stackframe/stack-shared/dist/schema-fields. Don't use regular yup imports. Example:
import { yupObject, yupString, yupNumber } from "@stackframe/stack-shared/dist/schema-fields";A: Projects belong to teams via the ownerTeamId field. Teams exist within the internal project. Users can be members of multiple teams and have different permissions in each team.
A: Use template literals with backticks instead of quotes in JSX text content:
<Typography>{`Text with "quotes" inside`}</Typography>A: Internal API calls need:
X-Stack-Access-Type: 'server'X-Stack-Project-Id: 'internal'X-Stack-Secret-Server-Key: <server key>- Either
X-Stack-Auth: Bearer <token>or a session cookie
A: Use window.location.reload() after the action completes. This ensures the UI reflects the latest state from the server.
A: Routes follow Next.js App Router conventions in /apps/backend/src/app/api/latest/. Each route has a route.tsx file that exports HTTP method handlers (GET, POST, etc.).
A: Use user.useTeams() where user is from useUser({ or: 'redirect', projectIdMustMatch: "internal" }).
A: Client access type is for frontend applications and has limited permissions. Server access type is for backend operations and requires a secret key. Admin access type is for dashboard operations with full permissions.
A: If the schema defines auth.user as .defined(), TypeScript knows it can't be null, so checking if (!auth.user) causes a lint error. Remove the check or adjust the schema if the field can be undefined.
A: This happens when packages haven't been built yet. Run these commands in order:
pnpm clean && pnpm i && pnpm codegen && pnpm build:packagesThen restart the dev server. This rebuilds all packages and generates the necessary TypeScript declarations.