Tenant-Aware Account Access in Go — Identity, Authorization, and Recovery in 2026

go dev.to

Short answer: treat user identity, tenant membership, and application authorization as separate records, then require every signup, login, and recovery transition to preserve that separation. A CAPTCHA can slow automated edtech registrations, but it must never decide which tenant a user belongs to or what that user may do. The decisive constraint is account recovery: if recovery can silently select, create, or merge a tenant membership, the cleanest login architecture has already been bypassed.

This design is less convenient than carrying a single tenant_id on the user row. The extra state is justified because identity answers who authenticated, membership answers where that identity participates, and authorization answers which action is allowed there. Those are different audit facts.

What should tenant-aware user identity and application authorization separate?

Start with an identity that is global only within the authentication system's stated namespace. An identity can own several authenticators and recovery methods, but it does not inherit application permissions merely because an email address was verified. A tenant membership joins that identity to one school, district, or course operator; roles and policy bindings attach to the membership or to resources inside that tenant. The request context then carries an authenticated identity ID and an explicitly selected tenant ID, while the authorization layer resolves permissions from current membership data.

That boundary blocks a common category error. Suppose teacher@example.edu belongs to District Red as an instructor and District Blue as a billing viewer. Authentication may establish one identity, but it cannot produce a universal teacher role. The application must select a tenant through an already established membership, check that the membership is active, and evaluate the requested action within that tenant. Hostnames, URL parameters, and client-supplied headers are routing hints; none is authorization evidence.

Keep the records conceptually distinct:

Record Answers Must not decide
Identity Which principal authenticated? Tenant access
Authenticator or recovery factor How was control demonstrated? Application role
Membership In which tenant may the principal act? Authentication strength
Policy binding Which actions are allowed on which resources? Identity ownership
Audit event What decision occurred, under which context? Future policy

The separation also changes uniqueness rules. An email address may be a login identifier, a contact address, or both, yet a tenant invitation is still a tenant-scoped grant that must be accepted by an authenticated identity. Avoid inferring a membership from a matching domain or accepting an invitation merely because a session presents the same display email. OWASP recommends generic authentication responses so account existence is not exposed; the same discipline should cover invitations and recovery lookups.

Put the CAPTCHA before registration work, not inside authorization

For an edtech signup, validate the CAPTCHA response before creating an identity, sending verification mail, reserving a classroom slug, or writing an invitation acceptance. The server verifies the challenge and records only the minimal decision metadata needed for replay control and audit. It then executes a separate, idempotent registration command. CAPTCHA failure ends that attempt; CAPTCHA success permits the workflow to continue, but grants no tenant membership.

The distinction is sharp.

A useful command boundary in Go looks like this:

package signup

import (
    "context"
    "errors"
)

type RegisterCommand struct {
    IdempotencyKey string
    Email          string
    InviteToken    string
    CaptchaToken   string
}

type CaptchaVerifier interface {
    Verify(ctx context.Context, token string) (bool, error)
}

type RegistrationStore interface {
    ExecuteOnce(ctx context.Context, cmd RegisterCommand) (identityID string, err error)
}

func Register(ctx context.Context, verifier CaptchaVerifier, store RegistrationStore, cmd RegisterCommand) (string, error) {
    accepted, err := verifier.Verify(ctx, cmd.CaptchaToken)
    if err != nil {
        return "", err
    }
    if !accepted {
        return "", errors.New("registration challenge rejected")
    }
    return store.ExecuteOnce(ctx, cmd)
}
Enter fullscreen mode Exit fullscreen mode

ExecuteOnce is deliberately a domain interface rather than a claim that a database transaction produces exactly-once delivery. The implementation should place the idempotency key, normalized request fingerprint, resulting identity ID, and any invitation-consumption event in one durable transaction. A retry with the same key and same fingerprint returns the recorded result; the same key with different input is rejected. Downstream email should be emitted from an outbox so a transaction retry does not send two messages.

Do not log the CAPTCHA token, recovery secret, session credential, or full invitation token. An audit event can retain a correlation ID, identity ID once known, tenant invitation ID, decision code, timestamp, and policy version. This supports reconciliation without turning the audit trail into a credential store. Retention periods and access controls depend on the applicable education and privacy obligations; I'm not sure a universal period exists, because the answer changes with jurisdiction, school contracts, and the data recorded. Legal and security owners must settle that policy before deployment.

Recovery is a new authentication ceremony, not a membership shortcut

Recovery deserves its own state machine. Begin with a generic response, issue a single-use opaque token through an enrolled channel, store only a protected representation of that token, set an expiry, and consume it atomically. After a successful reset, invalidate or rotate relevant sessions according to the application's security policy and create an audit event that can be reconciled with the request and notification events. OWASP's guidance covers consistent responses, side-channel resistance, reauthentication after risk events, and careful session handling; those controls remain necessary even when a CAPTCHA guards the first request.

The dangerous step comes after proof of control. Recovery restores access to an identity; it does not manufacture membership in the tenant implied by the current hostname. If the recovered identity has several memberships, present an explicit tenant choice only from active memberships returned by the server. If it has none, route it to invitation acceptance or enrollment. Never attach it to the tenant whose login page happened to initiate recovery.

Consider two concurrent requests using the same recovery token. Both may pass a preliminary token lookup, so the final transition must be a conditional write from issued to consumed. Exactly one request wins. Record the winning event in the same transaction as the credential change, and give every audit event a stable event ID; consumers can then deduplicate while reconciliation detects missing projections. This is an exactly-once mindset applied honestly: atomic state transition plus idempotent effects, not a promise that networks deliver once.

There is a usability cost. A global identity with explicit tenant selection adds a screen for people who belong to multiple schools, and strict recovery may send a user back through an invitation flow rather than guessing their organization. Don't hide that friction by auto-linking on email domain. Optimize it with remembered, server-validated context and clear organization names, while keeping the grant boundary intact.

How should authorization recheck tenant context at each resource boundary?

A gateway can authenticate a session, but resource authorization belongs close to the data operation. Every command should carry identity, selected tenant, action, resource, and a correlation ID. The service loads the active membership and policy; the repository also scopes reads and writes by tenant so a missed policy check does not turn an object identifier into cross-tenant access. For batch jobs and message consumers, tenant context belongs in the signed or integrity-protected job envelope and is validated again before execution.

A compact decision shape is Allow(identity, tenant, action, resource, context). Log the decision code and policy version, not a dump of sensitive claims. Denials should be observable by tenant and action, but external errors should avoid revealing that a resource exists in another tenant. Tests need a cross-product: same identity across two tenants, same resource identifier under different tenants, inactive membership, changed role, stale session, consumed recovery token, repeated signup key, and an invitation addressed to an identity that is already a member elsewhere.

One long-lived trap is copying role claims into a session and trusting them until expiry. That makes revocation lag a property of token lifetime. Short-lived claims reduce the window but don't remove it; high-impact actions such as exporting student records, changing recovery factors, or assigning administrators should re-evaluate current membership and may require recent authentication. The precise reauthentication threshold is a policy choice tied to risk and compliance, not a constant that can be copied from an example.

This architecture is not suitable when tenants require fully isolated identity namespaces, separate cryptographic keys, or independent compliance administration. In that case, use per-tenant identity realms or deployments and accept the operational duplication. Conversely, a small application in which every identity belongs to exactly one immutable tenant may choose a tenant-scoped identity model, provided recovery still cannot cross that scope. The catch is migration: once users can legitimately join several schools, merging tenant-local identities becomes a security-sensitive data project.

Roll out with shadow decisions and reconciled audit events

Introduce the model in compact stages. First, add immutable identity IDs and explicit membership IDs without changing decisions. Next, compute the new authorization result in shadow mode and compare it with the existing result, recording mismatches without exposing resource data. Then enforce the new check on read-only endpoints, followed by mutations and administrative actions. Move recovery last only if the old recovery flow is temporarily constrained so it cannot create or switch memberships; otherwise recovery is the first boundary to fix.

Before each stage, reconcile counts among accepted invitations, active memberships, authentication identities, and audit events. Sample traces should connect CAPTCHA decision, registration idempotency record, identity creation, invitation consumption, and membership activation through stable correlation identifiers. Alert on impossible transitions, such as a membership activation without an accepted invitation or administrative grant. Keep a rollback path for enforcement policy, but never roll back the audit schema or discard decision evidence.

Done means more than successful login. It means a recovered identity cannot acquire a new tenant, a replayed signup cannot create a second account, an authorization decision can be explained after policy changes, and each cross-system effect can be reconciled to one durable command.

References

Source: dev.to

arrow_back Back to Tutorials