Short answer: for a property-management portal, let Google and GitHub prove identity, but keep account ownership, session issuance, and revocation in your database; use native credentials only when you can operate the password lifecycle as carefully as a ledger.
That boundary is the architecture decision. A social provider can authenticate a person, yet it cannot know which landlord, building, or maintenance role that person is allowed to exercise in your system. The application must bind an external subject to an internal account, record the binding, and issue a session whose lifetime reflects the risk of changing leases or payment instructions. In a real property portfolio, the same person may be an owner in one building and a vendor in another, so authorization has to be evaluated after authentication rather than inferred from a provider profile.
I approach this like a reconciliation problem. A login callback is an input event, not permission to mutate state twice. The invariants are simple: one provider subject maps to one internal identity, a callback can be retried without creating two sessions, and every authentication decision leaves an audit trail that survives the session itself.
What should a property portal own when Google and GitHub authenticate a user?
The first step is to name the ownership fields before choosing a flow. Store an internal account_id as the stable owner of data. Store (issuer, subject) as the provider identity key; an email address is useful for display and recovery, but it is not a durable identity key because addresses can change and providers can treat verification differently. Keep a separate membership table for account_id, property, and role. That prevents a sign-in assertion from silently granting access to every property managed by the company.
OAuth authorization code flow with PKCE is the sensible default for browser-based Google and GitHub sign-in. The browser receives a state value and a code challenge, the server exchanges the short-lived code, validates the issuer and redirect target, then looks up the subject. The server, not the browser, creates the application session. Native credentials follow the same final steps, but the proof is a password verifier that your service must protect and retire.
The distinction matters during account linking. Do not merge records merely because two callbacks contain the same email. Require a logged-in user to prove control of the existing account, then record an explicit link event. In a finance-minded audit log, that event includes the internal account, issuer, subject, actor session, timestamp, and reason. If an owner later disputes a payout-change action, you can reconstruct which identity was linked and which session authorized it.
The lifecycle decision: short sessions, rotating refresh tokens, or both
Session security and user friction pull in opposite directions. A two-hour browser session limits the window after a stolen cookie, but it asks a property manager to sign in during a site inspection. A long-lived refresh token reduces prompts, but it turns token theft into a durable incident unless rotation and reuse detection are implemented.
For the portal, issue a short-lived, opaque session cookie and keep refresh state server-side. Mark the cookie Secure, HttpOnly, and SameSite=Lax unless a deliberate cross-site flow requires a different policy. Bind the record to a session identifier, a creation time, an absolute expiry, and a revocation timestamp. Step-up authentication should be required for high-impact actions such as changing a bank account or transferring property ownership, even when the browser session is still valid.
Here is the critical path in Go. The handler treats the callback as at-least-once input: the unique provider key and a transaction make retries harmless.
package auth
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"errors"
"net/http"
"time"
)
type Identity struct {
Issuer string
Subject string
}
type Store interface {
FindOrCreateAccount(context.Context, Identity) (string, error)
CreateSession(context.Context, string, time.Time) (string, error)
AuditLogin(context.Context, string, Identity, string, time.Time) error
}
func randomID() (string, error) {
b := make([]byte, 32)
if _, err := rand.Read(b); err != nil {
return "", err
}
return base64.RawURLEncoding.EncodeToString(b), nil
}
func Callback(store Store, expectedState, receivedState string, identity Identity) (http.HandlerFunc, error) {
if subtle.ConstantTimeCompare([]byte(expectedState), []byte(receivedState)) != 1 {
return nil, errors.New("state validation failed")
}
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
now := time.Now().UTC()
accountID, err := store.FindOrCreateAccount(ctx, identity)
if err != nil {
http.Error(w, "authentication unavailable", http.StatusUnauthorized)
return
}
sessionID, err := store.CreateSession(ctx, accountID, now.Add(2*time.Hour))
if err != nil {
http.Error(w, "authentication unavailable", http.StatusUnauthorized)
return
}
_ = store.AuditLogin(ctx, accountID, identity, sessionID, now)
http.SetCookie(w, &http.Cookie{Name: "pm_session", Value: sessionID, Path: "/", Secure: true, HttpOnly: true, SameSite: http.SameSiteLaxMode, MaxAge: 7200})
http.Redirect(w, r, "/", http.StatusFound)
}, nil
}
The code deliberately has no password or provider SDK in the session boundary. The provider adapter validates tokens and returns an Identity; the store enforces a unique constraint on (issuer, subject). In production I would also make the audit write part of the same transaction as session creation, or enqueue an immutable event before returning success. A missing audit row is a compliance signal, not a logging detail.
Where do OAuth and native credentials fail differently?
OAuth moves password risk out of the portal, but it introduces redirect, state, nonce, issuer, and account-linking boundaries. A loose redirect URI can become an authorization-code theft path. A callback that accepts an ID token without checking its issuer can bind an attacker-controlled identity. PKCE protects the code exchange from interception, while state protects the browser transaction; neither one decides your authorization policy.
Native credentials have a different failure surface. Password reset tokens, breached-password screening, rate limits, MFA enrollment, and verifier parameters become your operational obligations. Hash passwords with a modern adaptive function, keep reset tokens one-use and time-limited, and never place the password or reset secret in an audit record. The convenience of a familiar login form is purchased with a permanent security workload.
The comparison is easier to make as a decision record:
| Concern | OAuth with Google/GitHub | Native credentials |
|---|---|---|
| Identity proof | Provider subject after code exchange | Local password verifier plus optional MFA |
| Account ownership | Your (issuer, subject) mapping and link approval |
Your account table is the primary identity |
| Session lifecycle | Your cookie, expiry, rotation, and revocation | The same controls, plus reset and password-change invalidation |
| Main failure boundary | Redirect, token validation, and provider availability | Password storage, recovery, and abuse resistance |
| Best fit | Users already holding a trusted provider account | Controlled populations needing an independent login path |
Neither column removes the need for authorization checks. A GitHub identity can authenticate a contractor while still lacking membership in Building 42; a local password can authenticate a former employee whose membership was revoked yesterday. Authorization should query current membership on every sensitive request, with cached decisions carrying a short and explicit freshness window.
How can teams test and operate the session boundary?
Test the state machine, not just the happy-path redirect. A useful suite replays a callback twice, swaps the issuer, changes the subject, expires the session, revokes membership, and submits a reset token twice. Each case should assert both the HTTP result and the durable rows: one account link, one session, and one audit event for one accepted login.
Observability should expose correlation identifiers without exposing tokens. Count callback outcomes by reason, session revocations by actor, and linking attempts by issuer. Alert on a spike in failed state checks or refresh-token reuse. Keep provider error details out of user-facing messages; a generic failure avoids leaking which account exists while the structured internal event preserves diagnosis.
There is a practical limit here. OAuth is not suitable when your organization cannot tolerate dependency on an external identity provider during an outage or when policy requires an independently managed credential; retain native credentials as a carefully protected fallback in that case. Native credentials are not suitable when no team can own recovery, MFA, and breach response; choose a provider flow and document the dependency instead. Your mileage may vary because regulatory retention periods and workforce policy differ, but the decision should be recorded as an explicit risk acceptance, not hidden in a framework default.
The catch is operational ownership. OAuth is not suitable when your organization cannot tolerate dependency on an external identity provider during an outage; stick with native credentials when policy requires an independently managed login path and you have a team for recovery, MFA, and breach response. Native credentials are not suitable when nobody can own that workload, so use a provider flow and document the dependency. Your mileage may vary because regulatory retention periods and workforce policy differ, but the decision should be recorded as an explicit risk acceptance, not hidden in a framework default.
The rejected option in this record is a long-lived provider access token stored in the browser. It looks frictionless, yet it expands the blast radius, couples application sessions to provider scopes, and makes revocation semantics difficult to explain to an auditor. It is valid for a server-to-server integration that needs delegated API access, not for the session cookie that guards a property-management portal.
Three words guide the implementation: own the session.
Top comments (0)