DEV Community

CaspianHayes3586
CaspianHayes3586

Posted on

Developer Portal Forgot-Password Audits — Go Sessions and Public-Key Verification in 2026

Short answer: for a B2B SaaS developer portal, keep the browser in a short, server-managed session and use public-key verification only for signed, non-browser credentials; make password recovery a separately audited state transition. That split keeps routine work low-friction while giving investigators a narrow trail when a reset is abused.

I care about the page that fired, not the dashboard that looked green. A forgotten-password alert is useful only when the event record tells me which account, session, key, and recovery step were involved. The failure mode is familiar: a team adds a convenient reset link, leaves an old session alive, and discovers during an audit that nobody can prove what happened next.

What should a 2026 developer portal authentication flow prove?

An auditor should be able to follow one request from identity proof to session issuance and eventual revocation. Record a reset-request event, but don't treat the request as proof of ownership. Send a single-use, time-limited token through an already verified channel, consume it atomically, and require a fresh password policy check before issuing a new session. OWASP recommends generic responses for account recovery so an attacker cannot enumerate accounts; the same response and similar timing should apply whether an address exists.

That is the boundary.\n\nThe new password must invalidate the recovery token and every session whose assurance depends on the old credential. Keep API keys and signed service credentials on a separate revocation path. A browser cookie is state held by your service; a public-key signature is evidence presented by a client. Mixing those lifecycles is how a reset becomes a quiet persistence mechanism.

Keep the event schema boring and queryable: actor, subject, request_id, session_id, credential_id, method, result, occurred_at, and a reason code. Hash or otherwise minimize sensitive values. Logs should support a timeline without becoming a second password database.

Choosing sessions or public-key verification at the right boundary

Sessions suit interactive portal work because the server can revoke them centrally and the browser need not carry a long-lived signing secret. Set Secure, HttpOnly, and SameSite cookie attributes, rotate the session identifier after login and recovery, and enforce an idle and absolute lifetime that matches the portal's risk. Re-authentication for changing email, organization ownership, or recovery factors is friction worth paying.

Public-key verification fits automation: a client signs a canonical request, the server checks the key id, algorithm, timestamp, and nonce, then applies authorization. The private key stays with the client, while the portal stores the public key and its status. Rotation and revocation are explicit operations, so an audit can distinguish a bad signature from a disabled key. Do not accept an algorithm selected by the token itself; bind accepted algorithms in server configuration and reject ambiguous encodings.

Here is the small verification boundary I want covered by tests. It intentionally returns a generic error to callers while preserving a reason code for operators.

package auth

import (
    "crypto/ed25519"
    "errors"
    "time"
)

var ErrUnauthorized = errors.New("unauthorized")

type SignedRequest struct {
    KeyID     string
    Timestamp time.Time
    Nonce     string
    Body      []byte
    Signature []byte
}

func Verify(req SignedRequest, key ed25519.PublicKey, now time.Time) error {
    if req.KeyID == "" || req.Nonce == "" {
        return ErrUnauthorized
    }
    if now.Sub(req.Timestamp) > 2*time.Minute || req.Timestamp.Sub(now) > 30*time.Second {
        return ErrUnauthorized
    }
    message := append([]byte(req.KeyID+"\n"+req.Nonce+"\n"), req.Body...)
    if !ed25519.Verify(key, message, req.Signature) {
        return ErrUnauthorized
    }
    return nil
}
Enter fullscreen mode Exit fullscreen mode

The replay store is part of the contract, not an implementation detail: accept a nonce once within the timestamp window, bind it to the key id, and expire it. Your mileage may vary on the exact window; document the clock assumptions and measure drift before choosing one.

How do sessions, public-key verification, and recovery work together?

Treat the flow as a state machine. recovery_requested can lead to recovery_verified, then password_changed; only the final transition revokes existing browser sessions and marks the one-time token consumed. A signed automation request never upgrades a browser session by itself. Conversely, a browser reset should not silently rotate an organization's deployment key.

At the edge, rate-limit by account and network signal, add a per-token attempt limit, and queue email delivery so response timing stays predictable. At the service, make the consume-and-revoke operation transactional. This is where a seemingly minor race turns into an audit gap: two requests can arrive within milliseconds, both observe an unused token, and both attempt to mint a session; the database constraint must let exactly one win, while the loser receives the same generic response and the event stream records both request ids, the winning transition, and the reason for rejection. Model the sequence explicitly. Request A locks the recovery record, checks its expiry and consumed state, changes the password hash, marks the token consumed, revokes the account's browser sessions, and commits; request B then reads a consumed record and stops before session issuance. The mail gateway's delivery event is useful context, but it isn't evidence that the owner opened the message. Likewise, a successful token check isn't permission to skip authorization checks on the next page. At the audit layer, correlate the request, transaction, old session family, and newly issued session without storing the raw token, so the responder can reconstruct the transition from immutable identifiers instead of guessing from email logs. If any transition lacks that link, deployment should stop — a green success-rate chart can't repair a broken chain of evidence.

Test the ugly paths: two concurrent token submissions, a reset followed by an old-cookie request, a valid signature with a stale timestamp, a reused nonce, and a key revoked between verification and authorization. Property tests are useful for canonicalization because a harmless JSON field-order change must not create a second signed meaning.

Verification, rollback, and the friction budget

Before rollout, replay production-shaped traces in a staging tenant with synthetic identities. Confirm that recovery emails don't reveal account existence, that session rotation is visible in the event stream, and that an operator can revoke a key or all sessions without database surgery. Alert on unusual reset velocity, repeated failures for one subject, and successful recovery followed by a new geography or key.

Roll out behind a tenant flag. If telemetry shows a spike in legitimate lockouts, pause new enforcement while keeping already-issued recovery tokens bounded and auditable; don't restore the pre-reset session behavior just to make a graph look calm. The catch is that stronger revocation creates support work, so publish a recovery contact path and an emergency, dual-approval procedure.

This design is not suitable when a portal must support offline clients that cannot maintain clock discipline or rotate keys; use a protocol designed for that constraint and accept its additional ceremony. Stick with a simpler session-only model for a low-risk internal tool, provided its reset events and revocation semantics are still explicit. I am not sure any universal timeout exists: threat model, email latency, and operator response time should set the values, then tests should hold them steady.

The practical decision rule is plain: sessions for people, public-key signatures for software, and password recovery as a revoking event rather than a login shortcut.

References

Top comments (0)