Anonymous Authentication
Introduction
Anonymous authentication enables temporary, scoped access to specific resources
using pre-signed keys generated by Kraken. Unlike standard login which provides
full account access with refreshable tokens, anonymous auth creates time-limited
scopedToken cookies with restricted scope, granting access only to specific
resources. This makes it ideal for scenarios where you need to grant temporary
access without requiring user registration.
- Pre-signed keys: Generated server-side via Kraken API and can be reused multiple times
- Time-limited access: Scoped tokens expire after their set duration (typically around 1 hour)
- Resource-specific: Each token is scoped to specific resources or accounts
- No automatic refresh: The auth package does not automatically refresh expired tokens (users can re-authenticate using the same pre-signed key)
Getting started
Complete the Getting Started: Pages Router or Getting Started: App Router guide
Configuration
Basic Configuration (single anon path)
import { createAuthConfig } from "@krakentech/blueprint-auth";
export const authConfig = createAuthConfig({
appRoutes: {
anon: {
// Extract pre-signed key from URL path
getAnonParams({ url }) {
if (url.pathname.startsWith("/anon")) {
const [_anon, preSignedKey] = url.pathname.split("/");
if (preSignedKey) {
return { preSignedKey };
}
}
},
// Single pathname
pathname: "/anon",
},
dashboard: { pathname: "/dashboard" },
login: { pathname: "/login" },
},
});
Advanced Configuration (multiple paths with custom redirects)
import { createAuthConfig } from "@krakentech/blueprint-auth";
import { NextResponse } from "next/server";
export const authConfig = createAuthConfig({
appRoutes: {
anon: {
getAnonParams({ url }) {
// Support /anon/{key}/... paths
if (url.pathname.startsWith("/anon")) {
const [_anon, preSignedKey] = url.pathname.split("/");
if (preSignedKey) {
return { preSignedKey };
}
}
// Support dashboard with ?key={key} search param
if (url.pathname.startsWith("/feedback")) {
return { preSignedKey: url.searchParams.get("key") };
}
},
// Multiple pathnames
pathname: ["/anon", "/feedback"],
// Optional: Custom redirect on success
customSuccessResponse({ url }, { redirect }) {
// Example: redirect to clean URL without /anon prefix
const cleanPath = url.pathname.replace("/anon", "/feedback");
return redirect(new URL(cleanPath, url.origin));
},
// Optional: Custom error handling
customErrorResponse({ url, errorCode }, { redirect }) {
return redirect(new URL(`/error?code=${errorCode}`, url.origin));
},
},
dashboard: { pathname: "/dashboard" },
login: { pathname: "/login" },
},
});
Configuration Options
| Option | Type | Required | Description |
|---|---|---|---|
pathname | string | string[] | ✅ | Routes where anonymous auth is enabled |
getAnonParams | (options: { url: NextURL }) => { preSignedKey: string | null | undefined } | undefined | ✅ | Extracts preSignedKey from URL |
customSuccessResponse | (options: { url: NextURL }, helpers: { redirect, rewrite }) => NextResponse | undefined | ❌ | Override redirect after successful authentication |
customErrorResponse | (options: { url: NextURL; errorCode: ErrorCode }, helpers: { redirect, rewrite }) => NextResponse | undefined | ❌ | Override redirect on authentication failure |
Providing a parent route in pathname automatically grants access to all child
routes. For example:
pathname: "/anon"grants access to/anon/{key},/anon/{key}/account/123,/anon/{key}/account/123/feedback, etc.- This is useful for multi-step forms where the scoped token persists across
multiple pages (e.g.,
/anon/{key}/feedback/step-1,/anon/{key}/feedback/step-2,/anon/{key}/feedback/step-3)
Use allowList with glob patterns to make specific anon subroutes publicly
accessible:
anon: {
pathname: "/anon",
getAnonParams({ url }) { /* ... */ },
allowList: ["/anon/public/**"], // No pre-signed key required
}
See the picomatch docs for supported glob patterns.
Middleware
- Next.js ≤15
- Next.js 16+
import { createAuthMiddleware } from "@krakentech/blueprint-auth/middleware";
import { authConfig } from "@/lib/auth/config";
export const middleware = createAuthMiddleware(authConfig);
export const config = {
matcher: ["/anon/:path*", "/dashboard/:path*", "/login"],
};
import { createAuthMiddleware } from "@krakentech/blueprint-auth/middleware";
import { authConfig } from "@/lib/auth/config";
export const proxy = createAuthMiddleware(authConfig);
export const config = {
matcher: ["/anon/:path*", "/dashboard/:path*", "/login"],
};
The middleware matcher array must include your appRoutes.anon.pathname
value(s), otherwise the anonymous auth flow won't trigger.
Anonymous route
Create a page that accepts a pre-signed key and account number in the URL path.
The middleware automatically sets the scopedToken cookie when users navigate
to the URL, allowing you to use server functions like
getUserScopedGraphQLClient to fetch data.
- Pages Router
- App Router
import type {
GetServerSidePropsContext,
InferGetServerSidePropsType,
} from "next";
import { FeedbackForm } from "@/components/FeedbackForm";
import { graphql } from "@/lib/graphql";
import { getUserScopedGraphQLClient } from "@/lib/auth/server";
export default function FeedbackPage({
account,
}: InferGetServerSidePropsType<typeof getServerSideProps>) {
return (
<>
<h1>Customer Feedback</h1>
<FeedbackForm account={account} />
</>
);
}
const FeedbackFormQuery = graphql(`
query FeedbackForm($accountNumber: String!) {
account(accountNumber: $accountNumber) {
id
number
balance
}
}
`);
export async function getServerSideProps(
context: GetServerSidePropsContext<{
preSignedKey: string;
accountNumber: string;
}>,
) {
const { accountNumber } = context.params;
// The scopedToken cookie is already set by middleware
// Use server functions normally (they'll use the scoped token)
const graphQLClient = getUserScopedGraphQLClient({ context });
// Fetch data for the page
const { account } = await graphQLClient.request(FeedbackFormQuery, {
accountNumber,
});
if (!account) {
return { notFound: true };
}
return { props: { account } };
}
import { getUserScopedGraphQLClient } from "@/lib/auth/server";
import { FeedbackForm } from "@/components/FeedbackForm";
import { graphql } from "@/lib/graphql";
import { notFound } from "next/navigation";
const FeedbackFormQuery = graphql(`
query FeedbackForm($accountNumber: String!) {
account(accountNumber: $accountNumber) {
id
number
balance
}
}
`);
export default async function FeedbackPage({
params,
}: PageProps<"/anon/[preSignedKey]/[accountNumber]/feedback">) {
const { accountNumber } = await params;
// Fetch data using scoped token
const graphQLClient = getUserScopedGraphQLClient();
const { account } = await graphQLClient.request(FeedbackFormQuery, {
accountNumber,
});
if (!account) {
notFound();
}
return (
<>
<h1>Customer Feedback</h1>
<FeedbackForm account={account} />
</>
);
}
Session state
The middleware ensures users have valid tokens before reaching your page components. You don't need to check session state for security purposes.
Checking session state is useful when you need to differentiate behavior between
anonymous and fully authenticated users. Use getSession or useSession to
conditionally render UI elements or execute different application logic based on
authentication type.
Session state fields
| Field | Value | Description |
|---|---|---|
authMethod | "scoped" | Indicates user is authenticated via anonymous auth |
isAuthenticated | true | User has valid authentication (any supported auth cookie) |
Common patterns
- Client-Side UI
- Conditional UI
- Conditional Fetching
Client components need to conditionally render features based on whether the user authenticated via pre-signed key or full login.
A client-side feedback form that conditionally shows "Save Draft" and submission history links only to fully authenticated users, while displaying a one-time submission warning for anonymous users.
Show implementation
"use client";
import Link from "next/link";
import { useSession } from "@/lib/auth/client";
import { useState } from "react";
export function FeedbackForm({ accountId }: { accountId: string }) {
const { data: session } = useSession();
const [feedback, setFeedback] = useState("");
return (
<form>
<textarea
value={feedback}
onChange={(e) => setFeedback(e.target.value)}
placeholder="Tell us about your experience..."
/>
{session.authMethod !== "scoped" && (
<>
<button type="button">Save Draft</button>
<Link href="/feedback/history">View Previous Feedback</Link>
</>
)}
{session.authMethod === "scoped" && (
<p className="warning">
This is a one-time submission. You won't be able to edit it later.
</p>
)}
<button type="submit">
{session.authMethod === "scoped"
? "Submit (One-time)"
: "Submit Feedback"}
</button>
</form>
);
}
You have a route accessible by both anonymous users (via pre-signed key) and fully authenticated users, but want to provide different experiences for each.
A feedback page that branches based on session type. Anonymous users see a simplified form for the single account in their scoped token, while fully authenticated users can select from all their accounts, save drafts, and access their submission history.
Show implementation
import Link from "next/link";
import {
getSession,
getUserScopedGraphQLClient,
getUserGraphQLClient,
} from "@/lib/auth/server";
import { graphql } from "@/lib/graphql";
const AccountQuery = graphql(`
query GetAccount($accountNumber: String!) {
account(accountNumber: $accountNumber) {
id
number
}
}
`);
const AllAccountsQuery = graphql(`
query GetAllAccounts {
accounts {
id
number
}
}
`);
export default async function FeedbackPage() {
const session = await getSession();
if (session.authMethod === "scoped") {
const graphQLClient = getUserScopedGraphQLClient();
const { account } = await graphQLClient.request(AccountQuery, {
accountNumber: session.accountNumber,
});
return (
<div>
<h1>Feedback for Account {account.number}</h1>
<p>You have limited access via email link</p>
<FeedbackForm accountId={account.id} />
</div>
);
}
const graphQLClient = getUserGraphQLClient();
const { accounts } = await graphQLClient.request(AllAccountsQuery);
return (
<div>
<h1>Submit Feedback</h1>
<AccountSelector accounts={accounts} />
<FeedbackForm />
<PreviousFeedbackHistory />
<Link href="/dashboard">Back to Dashboard</Link>
</div>
);
}
A single page component handles requests from multiple entry points (dashboard and anonymous email link), requiring different data fetching strategies.
A meter reading submission page accessible both from the dashboard and via email link. Anonymous users have their account pre-determined by the scoped token, while authenticated users can select which of their multiple properties to submit readings for.
Show implementation
import {
getSession,
getUserScopedGraphQLClient,
getUserGraphQLClient,
} from "@/lib/auth/server";
import { graphql } from "@/lib/graphql";
import { redirect } from "next/navigation";
const AccountQuery = graphql(`
query GetAccountWithMeters($accountNumber: String!) {
account(accountNumber: $accountNumber) {
id
number
properties {
address
meters {
id
serialNumber
fuelType
}
}
}
}
`);
const AllAccountsQuery = graphql(`
query GetAllAccounts {
accounts {
id
number
}
}
`);
export default async function SubmitReadingPage({
searchParams,
}: {
searchParams: { accountNumber?: string };
}) {
const session = await getSession();
if (!session.isAuthenticated) {
redirect("/login");
}
let account;
if (session.authMethod === "scoped") {
const graphQLClient = getUserScopedGraphQLClient();
const result = await graphQLClient.request(AccountQuery, {
accountNumber: session.accountNumber,
});
account = result.account;
} else {
const graphQLClient = getUserGraphQLClient();
if (!searchParams.accountNumber) {
const { accounts } = await graphQLClient.request(AllAccountsQuery);
return <AccountSelector accounts={accounts} />;
}
const result = await graphQLClient.request(AccountQuery, {
accountNumber: searchParams.accountNumber,
});
account = result.account;
}
if (!account) {
return <div>Account not found</div>;
}
return (
<div>
<h1>Submit Meter Reading</h1>
<p>Property: {account.properties[0].address}</p>
<MeterReadingForm meters={account.properties[0].meters} />
{session.authMethod !== "scoped" && (
<ReadingHistory accountId={account.id} />
)}
</div>
);
}
Security Considerations
- Pre-signed keys are sensitive: Treat them like passwords
- Use HTTPS only: Never send keys over unencrypted connections
- Time-limit tokens: Set appropriate expiration times when generating keys
- Validate scope server-side: Always verify the token scope matches the accessed resource
- No automatic refresh: The auth package does not automatically refresh expired tokens (users can re-authenticate using the same pre-signed key)
FAQ
How does anonymous authentication work?
- User receives URL with embedded pre-signed key (e.g.,
/anon/{key}/account/123/feedback) - User navigates to the URL
- Next.js middleware intercepts the request
getAnonParamsfunction extracts the pre-signed key from the URL- Middleware calls
obtainKrakenTokenGraphQL mutation with the key - Kraken validates the key and returns a scoped JWT token
- Middleware sets
scopedTokencookie with expiration from JWT payload - User gains access to the protected resource
- Token expires after the set duration
:::warning[No automatic refresh] The auth package does not automatically refresh scoped tokens. When a scoped token expires, users need to re-authenticate by navigating to a URL with their pre-signed key. Pre-signed keys can be reused multiple times within their validity period, which is typically longer than the scoped token lifetime. :::
How long do scoped tokens last?
The expiration time is determined when the pre-signed key is generated via the
Kraken API. The scoped token's exp claim contains the Unix timestamp when it
expires. This is typically set to around 1 hour, though it can vary depending on
the use case. Note that pre-signed keys themselves have separate, usually
longer, expiration times and can be reused to obtain new scoped tokens.
What happens if a user already has an accessToken cookie?
The auth middleware follows a priority system where higher-priority
authentication methods take precedence. If a user already has a valid
accessToken (full authenticated session), the scoped token will not override
it. The middleware will skip processing the pre-signed key and continue with the
existing authenticated session.
Priority order (highest to lowest):
masqueradeToken- Staff impersonationMWAuthToken- Mobile app integrationaccessToken- Full authenticated sessions (email/password, OAuth)scopedToken- Anonymous key access
This prevents accidental session replacement when an authenticated user clicks an anonymous session link.
Next Steps
Now that you have anonymous authentication configured, explore these related guides:
- Kraken OAuth: Enable OAuth-based authentication flows
- Masquerade: Implement staff impersonation for support scenarios
- Organization-Scoped Auth: Restrict authentication to specific organizations
- Building Forms: Create production-ready forms with validation and error handling
API Reference
Quick reference to relevant functions:
createAuthConfig: Configure anonymous auth routescreateAuthMiddleware: Enable middleware handlinggetSession: Check session state (isAuthenticated,authMethod)getUserScopedGraphQLClient: Make authenticated GraphQL requests with scoped token