Adds authentication to a genesis project. The user picks a tier and states requirements; the skill derives the mechanism — never quiz the user on implementation choices like JWT vs cookie — then writes the code directly into the target project.
This skill is a guideline, not a copy job. There is no reference implementation to clone: read the target project's existing code and write auth that matches it.
Prerequisite: the server needs the genesis DB layer — internal/db with goose migrations, sqlc, and the users table. The genesis CLI does not generate it. If it is missing, stop and tell the user to add it first with /genesis sqlc.
Tiers
| Tier | Use case | Includes |
|---|---|---|
basic |
Internal tools | Single admin seeded from env var, password login, cookie session in Postgres. No signup, no email. |
standard |
Normal apps | Email+password users with email verification, optional Google/Apple SSO, sessions in Postgres, password reset, admin/user roles. |
strict |
Sensitive data | standard + TOTP MFA, rate limiting + temporary lockout, session rotation on privilege change, audit log. Beyond this, recommend an external IdP (Keycloak, Auth0, WorkOS) instead of more homegrown code. |
Question flow
-
Read the arguments first.
basic/standard/strictanswers the tier;google/appleanswers SSO. Ask only what is still missing. -
Ask remaining questions in ONE AskUserQuestion call, with the recommended option first and marked "(Recommended)":
- Tier:
basic|standard (Recommended)|strict - Clients:
browser only (Recommended)|also mobile/API consumers - SSO providers (multiSelect):
google|apple— an empty selection means no SSO.
basicis browser-only with no SSO: when the arguments already saybasic, skip Clients and SSO; if the user picksbasicin the same call, ignore those answers. If the arguments combinebasicwith a provider, flag the conflict in the confirmation. - Tier:
-
Derive the mechanism — do not ask about it:
- browser only → cookie (HttpOnly, Secure, SameSite=Lax) + server-side session token in Postgres. The web app reaches the API same-origin via the Vite proxy / nginx, so add no CORS. Lax still sends cookies on cross-site GETs: keep state changes off GET, and reject cookie-authenticated mutations with a foreign
Origin. - mobile/API consumers → same cookie flow for the web, plus opaque bearer tokens from the same sessions table for mobile, and API keys for machine consumers. Not JWT: it cannot be revoked on logout, reset, or role change.
- browser only → cookie (HttpOnly, Secure, SameSite=Lax) + server-side session token in Postgres. The web app reaches the API same-origin via the Vite proxy / nginx, so add no CORS. Lax still sends cookies on cross-site GETs: keep state changes off GET, and reject cookie-authenticated mutations with a foreign
-
Confirm with the derived plan in prose, not more pickers. Example: "basic tier: seeded admin, cookie sessions in Postgres, no SSO, server only (no web login UI) — proceed?" If the user overrides a mechanism (e.g. "actually JWT") or asks for the web UI, name the trade-off and honor it.
Implementation rules
- Read the target project before writing anything. Its router, config struct, migration numbering, and package layout decide the shape of the new code. Match them; do not impose a layout from this document.
- Extend the existing users table, don't invent a parallel one. A genesis server already ships
internal/db/migrations/0001_create_users.sqlwith a uniqueusername,password_hash, and auser_roleenum. Add columns and new tables in a new numbered migration. Forstandard+, add a uniqueemailstored lowercased, log in by email, and setusernameto that email on signup. On a populated table, never assume usernames are emails: backfill before enforcingNOT NULL, and surface missing addresses and case collisions to the user instead of guessing. - Follow the established layout for a genesis server:
internal/auth/— password hashing, session/token issue and verify, provider clients. No gin types in here.internal/api/http/handler/auth.go— login, logout, signup, reset endpoints.internal/api/http/dto/auth.go— request/response structs.internal/api/http/middleware/auth.go— session/bearer verification, role checks.internal/db/migrations/NNNN_*.sql— goose-- +goose Up/Downblocks, both directions.internal/db/queries/*.sql— sqlc named queries; runmake generateafter editing (make depinstalls sqlc).
- Wire everything. Register handlers and middleware in
SetupRoute, add any new dependency to theServicesstruct, and construct it inmain.go. Auth code that compiles but is never mounted looks done and isn't. - Config goes through the existing mechanism. Define
auth.Configininternal/authand compose it into the struct incmd/<app>/config.go, likedb.Config. Put non-secret defaults inapplication.ymland every key in.env.exampleusing the.→_upper-case form (auth.sessionTtl→AUTH_SESSIONTTL). Keep secrets (seed password, client secrets, SMTP password, keys) out ofapplication.ymland tag themmask:"true": the loader dumps the whole config at debug, the default level. Match the loader's field binding (see theadderskill); never reados.Getenvdirectly. - Use the standard library and existing deps first.
golang.org/x/crypto/bcryptfor passwords,crypto/randfor tokens. Add a dependency only when the tier genuinely needs it (OIDC, TOTP), and rungo mod tidy. - Never log or return secrets. No password, hash, session token, or reset token in logs or error responses. Store only SHA-256 hashes of session, reset, verification, and API tokens (32 bytes from
crypto/rand); bcrypt is for passwords only, since token hashes must be indexable for lookup. Don't reveal whether an account exists: return the same error for an unknown user and a wrong password (compare against a dummy hash so timing matches), and the same response to every reset request. - Reset atomically. Validate/consume the reset token, update the password, and revoke sessions in one transaction. Lock or conditionally consume the token so concurrent requests cannot both succeed.
- Use
TIMESTAMPTZfor every timestamp column. The genesisuserstable already uses it (created_at,updated_at), and sqlc maps it topgtype.Timestamptz. Do the same for session/reset expiry and lifecycle columns; never mix in plainTIMESTAMP, which drops the offset and breaks expiry checks on a non-UTC database. - Expiry is not cleanup. Wire bounded periodic deletion of expired sessions and expired/used reset records, with suitable indexes.
- Validate bcrypt's byte limit. Reject passwords over 72 bytes with a client-validation error on signup/reset; character-count validators are insufficient.
- Send email through a
Mailerinterface ininternal/authwith an SMTP implementation. When SMTP is not configured, reset and verification requests return 503 and write no token rows; never log the link as stand-in delivery. Startup must not depend on the log level. - SSO. Use
golang.org/x/oauth2+github.com/coreos/go-oidc/v3; checkstate, PKCE, andnonce, and verify the ID token. Key identities by(provider, subject)in their own table, never by email, and makepassword_hashnullable for SSO-only users. Link to an existing user only when both sides' emails are verified — otherwise an attacker who pre-registers the victim's email keeps access. Apple posts the callback cross-site (form_post), so its state cookie needsSameSite=None; Secure; its client secret is an ES256 JWT valid at most 6 months, so mint it at runtime from the.p8key; it sends the user's name only on first sign-in. - Seed the first admin from config, in every tier. Create it only when no
Adminuser exists and never overwrite one; if none exists and the seed password is empty, fail startup with a clear error. Signup always createsUser; never acceptrolefrom the request. - Strict extras. Keep rate-limit and lockout state in Postgres (in-memory breaks with replicas), and set gin's trusted proxies to the real proxy chain — by default gin trusts all, so
ClientIP()reads a spoofableX-Forwarded-For. Lockouts expire and back off; a permanent one lets anyone lock out the admin. Encrypt TOTP secrets at rest, reject reused codes, and hash recovery codes.
Verification procedure
make buildpasses in the target server.make generateis clean and the generated code is in the tree.- A migration file exists — up and down — for every new table or column the code queries.
- Every new config key appears in
.env.example, non-secret ones also inapplication.yml, and secret fields carrymask:"true". - For cookie sessions: the cookie is set with
HttpOnly,Secure, andSameSite. - Session tokens are stored hashed, with an expiry column, and logout deletes the row.
- Run it — a passing build or
[no test files]is not verification. Start the project's docker-compose Postgres, run the server withLOG_LEVEL=info, and curl the flows: login, logout, a protected route, a role check. Migrate both a fresh database and one with existing users (they can still log in), and recheck expiry afterALTER DATABASE ... SET timezone = 'Asia/Singapore'. standard+: of two concurrent resets with one token, exactly one succeeds; a failed reset rolls back; 40 ×é(80 bytes) is rejected with a 4xx; expired rows get cleaned up.
Common mistakes to watch for
- Re-asking what arguments already answered.
/genesis-auth basicshould ask nothing except the final confirmation. - Migrations without a
Downblock, or editing an already-applied migration instead of adding a new one. - Hand-editing
internal/db/sqlc/. It is generated; change the.sqlquery and regenerate.