Anonymous sessions (sessions not tied to an authenticated identity) are not natively supported in Kratos today. The session model is deeply coupled to the identity model at the database, struct, and API level. Implementing anonymous sessions is feasible but requires changes across multiple layers. This report outlines the current architecture constraints, proposes API designs, and evaluates implementation approaches.
The Session struct has a non-nullable IdentityID uuid.UUID field and a hard foreign key constraint at the database level:
CREATE TABLE "sessions" (
"id" UUID NOT NULL,
PRIMARY KEY("id"),
"issued_at" timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP,
"expires_at" timestamp NOT NULL,
"authenticated_at" timestamp NOT NULL,
"identity_id" UUID NOT NULL,
"created_at" timestamp NOT NULL,
"updated_at" timestamp NOT NULL,
FOREIGN KEY ("identity_id") REFERENCES "identities" ("id") ON DELETE cascade
);
The persistence layer explicitly rejects sessions without an identity:
s.NID = p.NetworkID(ctx)
if s.Identity != nil {
s.IdentityID = s.Identity.ID
} else if s.IdentityID.IsNil() {
return errors.WithStack(herodot.ErrInternalServerError.WithReasonf("cannot upsert session without an identity or identity ID set"))
ManagerHTTP.ActivateSession hard-requires a non-nil, active identity:
func (s *ManagerHTTP) ActivateSession(r *http.Request, session *Session, i *identity.Identity, authenticatedAt time.Time) (err error) {
// ...
if i == nil {
return errors.WithStack(x.PseudoPanic.WithReasonf("Identity must not be nil when activating a session."))
}
if !i.IsActive() {
return errors.WithStack(ErrIdentityDisabled.WithDetail("identity_id", i.ID))
}
The whoami handler unconditionally reads identity data and sets the X-Kratos-Authenticated-Identity-Id header:
// Set userId as the X-Kratos-Authenticated-Identity-Id header.
w.Header().Set("X-Kratos-Authenticated-Identity-Id", s.Identity.ID.String())
The tokenizer requires the session's identity for the sub claim:
func SetSubjectClaim(claims jwt.MapClaims, session *Session, subjectSource string) error {
switch subjectSource {
case "", "id":
claims["sub"] = session.IdentityID.String()
case "external_id":
if session.Identity.ExternalID == "" {
return errors.WithStack(herodot.ErrBadRequest.WithReasonf("The session's identity does not have an external ID set, but it is required for the subject claim."))
}
claims["sub"] = session.Identity.ExternalID.String()
Multiple hooks (e.g., SessionDestroyer, AddressVerifier, SessionIssuer) dereference s.Identity.ID without nil checks:
func (e *SessionDestroyer) ExecuteLoginPostHook(_ http.ResponseWriter, r *http.Request, _ node.UiNodeGroup, _ *login.Flow, s *session.Session) error {
return otelx.WithSpan(r.Context(), "selfservice.hook.SessionDestroyer.ExecuteLoginPostHook", func(ctx context.Context) error {
if _, err := e.r.SessionPersister().RevokeSessionsIdentityExcept(ctx, s.Identity.ID, s.ID); err != nil {
return err
}
return nil
})
}
A codebase-wide search for anonymous, guest, ephemeral in the context of sessions returned no relevant results. This is a greenfield feature.
# POST /sessions/anonymous
# Creates an anonymous session without requiring authentication.
# Request (Browser flow):
# No body required. Sets session cookie automatically.
# Request (API flow):
# No body required.
# Response 200:
{
"session": {
"id": "uuid",
"active": true,
"expires_at": "2024-01-01T00:00:00Z",
"issued_at": "2024-01-01T00:00:00Z",
"authenticated_at": "2024-01-01T00:00:00Z",
"authenticator_assurance_level": "aal0",
"authentication_methods": [
{ "method": "anonymous", "aal": "aal0", "completed_at": "..." }
],
"identity": null,
"anonymous": true,
"devices": [...]
},
"session_token": "ory_st_..." // Only for API flows
}
The whoami endpoint should gracefully handle anonymous sessions:
# GET /sessions/whoami
# Returns the current session. For anonymous sessions, identity is null.
# Response 200 (anonymous session):
{
"id": "uuid",
"active": true,
"anonymous": true,
"authenticator_assurance_level": "aal0",
"authentication_methods": [
{ "method": "anonymous", "aal": "aal0" }
],
"identity": null,
"devices": [...]
}
# Response 200 (authenticated session):
# Same as today, with "anonymous": false
When a user logs in or registers while holding an anonymous session, the session should be promotable:
# POST /self-service/login?flow=<flow_id>
# If the user has an active anonymous session cookie/token,
# the login flow promotes the anonymous session to an authenticated one.
# Behavior:
# 1. Anonymous session is revoked
# 2. New authenticated session is created
# 3. The anonymous session ID is available in the login hook context
# so that application logic can migrate anonymous data (e.g., cart)
# New hook context field:
# "previous_anonymous_session_id": "uuid" (available in post-login webhooks)
session:
anonymous:
# Enable anonymous session creation
enabled: false
# Lifespan of anonymous sessions (shorter than authenticated by default)
lifespan: 1h
# Maximum number of anonymous sessions per IP (rate limiting)
max_per_ip: 100
# Cookie name for anonymous sessions (separate from authenticated sessions)
cookie:
name: ory_kratos_anonymous_session
Create a lightweight "anonymous" identity behind the scenes for each anonymous session. This is the lowest-risk option.
| Aspect | Detail |
|---|---|
| Core idea | When an anonymous session is requested, create a special Identity with state: active, a dedicated schema_id: "anonymous", and empty traits. The session's IdentityID FK is satisfied. |
| Session struct change | Add Anonymous bool field to Session (new DB column is_anonymous). |
| Existing code impact | Minimal. All existing code that reads IdentityID or Identity continues to work. whoami can check s.Anonymous and null out the identity in the response. |
| Migration | One new column: ALTER TABLE sessions ADD COLUMN is_anonymous BOOL NOT NULL DEFAULT false. |
| Promotion | On login/registration, update the anonymous session's IdentityID to the real identity and set is_anonymous = false. Or revoke and create new. |
| Cleanup | Expired anonymous sessions are cleaned up by existing DeleteExpiredSessions. The phantom identities can be garbage-collected when their sessions expire. |
| Drawbacks | Creates identity records that aren't "real" users. Inflates identity counts. Needs logic to exclude anonymous identities from list/count endpoints. |
Make IdentityID nullable across the entire stack.
| Aspect | Detail |
|---|---|
| Core idea | Change IdentityID uuid.UUID → IdentityID uuid.NullUUID in the Session struct. Change DB column to nullable. |
| Blast radius | Very large. Every code path that references IdentityID or Identity must handle nil: UpsertSession, ActivateSession, GetSessionByToken, DoesSessionSatisfy, SetSessionDeviceInformation, Tokenizer, all hooks, all self-service flows, OpenAPI spec, generated clients. |
| Migration | ALTER TABLE sessions ALTER COLUMN identity_id DROP NOT NULL; ALTER TABLE sessions DROP CONSTRAINT sessions_identity_id_fkey; ADD CONSTRAINT ... ON DELETE SET NULL. |
| Drawbacks | High risk of nil-pointer panics. Breaks the invariant that every session has an owner. Difficult to validate completeness of nil-handling. |
Create a distinct anonymous_sessions table with its own handler.
| Aspect | Detail |
|---|---|
| Core idea | anonymous_sessions table with id, token, expires_at, metadata, devices. Completely separate from authenticated sessions. |
| Blast radius | Low on existing code. New code is isolated. |
| Migration | New table only, no changes to existing schema. |
| Drawbacks | Duplicates session management logic (cookie issuance, token handling, expiry, etc.). Two parallel systems to maintain. whoami must check both tables. Session promotion requires cross-table coordination. |
| Component | Approach A (Phantom) | Approach B (Nullable) | Approach C (Separate) |
|---|---|---|---|
session.Session struct |
+1 field | Change IdentityID type |
No change |
session.Persister |
Minor guard | Major refactor | New interface |
session.ManagerHTTP |
New method + guards | Refactor ActivateSession, FetchFromRequest, DoesSessionSatisfy |
New manager |
session.Handler |
New route + whoami guard |
Guards in every handler | New handler |
session.Tokenizer |
Guard for anonymous | Guard for nullable identity | Separate tokenizer logic |
| DB migration | 1 column | ALTER + FK change | New table |
| Self-service flows | Hook context extension | Nil-handling everywhere | Isolated |
| Hooks | Nil-guard in ~5 hooks | Nil-guard in ~5 hooks | N/A |
| OpenAPI spec | New endpoint + field | Modified session schema |
New endpoints + schema |
| Identity handler/pool | Exclude anonymous from counts | No change | No change |
| Risk | Low-Medium | High | Low |
| Effort | Medium (~2-3 weeks) | High (~4-6 weeks) | Medium (~2-3 weeks) |
Approach A (Phantom Identity) is recommended. It satisfies the FK constraint naturally, minimizes blast radius on existing code, and leverages all existing session infrastructure (cookies, tokens, expiry, cleanup, caching). The main trade-off—phantom identities inflating counts—is manageable by filtering on the is_anonymous column or a dedicated schema_id.
- Add
Anonymousfield toSessionstruct + DB migration. - Add new
CredentialsType:CredentialsTypeAnonymous = "anonymous"for the AMR. - Add
POST /sessions/anonymousendpoint insession.Handlerthat:- Creates a phantom identity with
schema_id: "anonymous"and empty traits. - Creates and activates a session with
Anonymous: true,AAL: aal0. - Issues cookie or returns token.
- Creates a phantom identity with
- Guard
whoami: Ifs.Anonymous, null out identity in response and skip theX-Kratos-Authenticated-Identity-Idheader. - Guard hooks: Add nil/anonymous checks in
SessionDestroyerand other hooks. - Session promotion: In the login/registration post-hook, detect if an anonymous session exists, revoke it, and pass the old session ID to webhooks via
transient_payloador a new hook context field. - Configuration: Add
session.anonymous.enabledandsession.anonymous.lifespan. - Identity list filtering: Exclude anonymous identities from
/admin/identitiesby default (or add a filter parameter). - Cleanup job: Extend
DeleteExpiredSessionsto also garbage-collect orphaned phantom identities whose sessions are all expired/revoked.
- Should anonymous sessions share the same cookie name? Using a separate cookie avoids interference but complicates promotion. Using the same cookie makes promotion seamless but means authenticated sessions overwrite anonymous ones.
- Should anonymous sessions be tokenizable (JWT)? The
subclaim has no meaningful identity. A session-ID-only JWT could work, but consumers expecting an identity subject would break. - Rate limiting: Without authentication, anonymous session creation is a DoS vector. Per-IP rate limiting and short lifespans are essential.
- Multi-tenancy (NID): Anonymous sessions should respect network isolation like authenticated sessions. No additional work needed since phantom identities inherit the NID.
- Hydra/OAuth2 integration: Anonymous sessions should likely not be usable as OAuth2 login sessions. The
AcceptLoginRequestflow requires an authenticated identity.