Use case — Migrating bcrypt passwords
The library verifies password hashes in argon2id PHC encoding ($argon2id$v=19$m=...,t=...,p=...$<salt>$<hash>). A users table carried over from another system usually holds bcrypt hashes instead. PrimaryPassword.LegacySchemes lets those hashes verify as they are, and the OP converts each one to argon2id the first time its owner signs in. No bulk reset, no second hash column, and no migration job: the table converts itself as users log in.
Use it when you bring an existing users table behind the OP through your own user store. If every password was created through this library, there is nothing to migrate.
Source:
examples/53-production-sql/users.goputs anapp_userstable with bcrypt hashes behind the login flow and rewrites each hash on the owner's next login.
Declare the scheme
op.WithLoginFlow(op.LoginFlow{Primary: op.PrimaryPassword{
Store: users,
LegacySchemes: []op.PasswordHashScheme{op.BcryptHashScheme()},
}})op.BcryptHashScheme() recognises hashes with the $2a$, $2b$ and $2y$ prefixes. For a stored hash that is not argon2id, the OP asks each scheme in LegacySchemes, in declaration order, whether it recognises the encoding, and the first one that does decides. A hash that no scheme recognises is treated like a wrong password, and the first one met is logged once per process as a WARN that names neither the user nor the hash and points at LegacySchemes. A nil element in the list makes op.New fail. Leaving the field nil or empty accepts argon2id only.
op.PasswordHashScheme is an interface with two methods. Recognizes(encoded []byte) bool selects the scheme from the stored encoding, and Verify(ctx, encoded, password) (bool, error) reports a match. Verify returns an error only for a backend fault, never for a mismatch. A second scheme for another format has the same shape as the bcrypt one.
What happens on a successful login
ReadPasswordHashreturns the stored hash, verbatim.- The scheme verifies the password against it.
- On a match, the OP encodes the password as argon2id and hands the result to
UpdatePasswordHash. - The next login finds an argon2id hash and verifies it natively.
Step 3 needs the store behind PrimaryPassword to implement the optional store.UserPasswordHashUpdater:
type UserPasswordHashUpdater interface {
UpdatePasswordHash(ctx context.Context, subject string, encoded []byte) error
}UpdatePasswordHash replaces the hash for subject, and it must return store.ErrNotFound for an unknown subject. Your table must be able to hold the new value, which is longer than the 60 characters of a bcrypt hash. Example 53 implements it as a single UPDATE on its own users table. Its ReadPasswordHash returns the stored value as it is, whatever the encoding: the OP sends a $2a$ hash to the scheme and an $argon2id$ one to its own verifier.
The rewrite never decides whether the user gets in. A failed write is logged, the login still succeeds, and the rewrite is retried on the next login.
A store that cannot update
Without the updater, the login works and the legacy hash stays as it is. Nothing is converted, and every login pays the bcrypt cost. A store may also implement the interface and answer store.ErrPasswordHashUpdateUnsupported. The OP then keeps the legacy hash and logs once, and the user signs in as before.
The shipped adapters differ:
| Store | UpdatePasswordHash |
|---|---|
op/storeadapter/sql | updates the oidc_users row |
op/storeadapter/dynamodb | updates the user item |
op/storeadapter/inmem | updates in memory |
op/storeadapter/composite | has no user store; pass a durable backend's own UserPasswords() |
The composite adapter carries no user store, so when you split users from the rest, hand the durable backend's UserPasswords() (which implements the updater) to PrimaryPassword.Store directly, as examples/17-spa-composite-store does.
Other login paths must accept argon2id too
Once UpdatePasswordHash has rewritten a user's hash, any other place in your application that checks that user's password with bcrypt directly (a legacy login form, a password-change screen, an API that takes the password) fails for that user. Pick the verifier from the stored prefix, using op.VerifyPassword for argon2id:
func checkPassword(hash []byte, password string) bool {
switch {
case bytes.HasPrefix(hash, []byte("$argon2id$")):
return op.VerifyPassword(hash, password)
case bytes.HasPrefix(hash, []byte("$2")):
return bcrypt.CompareHashAndPassword(hash, []byte(password)) == nil
}
return false
}Alternatively, do not implement UserPasswordHashUpdater until every path accepts argon2id; the hashes then stay bcrypt and nothing else changes.
What stays the same
- Lockout. A legacy hash goes through the same cross-factor lockout counter as an argon2id one. A locked subject is refused with the response a wrong password gets, and bcrypt verification does not bypass the brute-force gate.
- Unknown users. The dummy verification that keeps an unknown user indistinguishable from a wrong password applies to legacy hashes as well.
- Claims. Where
/userinfoand the ID token read claims is unchanged. With the store you pass asPrimaryPassword.Store, they come from the same table the password is checked against.
Limits worth knowing
The 72-byte limit. bcrypt only uses the first 72 bytes of a password. op.BcryptHashScheme() reports a mismatch for any password longer than 72 bytes instead of comparing a truncated prefix, so a longer string never matches by accident. A user whose old system truncated a longer password before hashing can no longer sign in, and cannot be converted either. That user needs a password reset.
Cost cap. A stored bcrypt hash with a cost above 15 is refused without running bcrypt, and the attempt reads as a password mismatch. That user needs a password reset.
Timing. Verifying a legacy hash takes a different time than verifying an argon2id one. The difference disappears for a user once the hash is rewritten.
Users who never return. A user who does not sign in keeps a bcrypt hash. Count the rows that still carry one with a query on your own table (a $2 prefix on the column). When the count is low enough, decide whether to retire those hashes and require a reset for the rest; the library has no opinion on when.
Next
- Bring your own user store: the interfaces behind
PrimaryPassword.Store. - Persistent storage (SQL): the shipped
oidc_userstable. - Start here: pick the next page by goal.