Authentication
This page shows how to log users in with dox_auth and protect routes with a JWT token, an access token or a session.
dox_auth checks an email and a password against your user table.
It gives back a JWT token, or an access token with AccessTokenAuthDriver.
The client sends this token in the Authorization header.
AuthMiddleware reads the token and loads the user from the database.
Install
A new app made with the dox CLI already has it.
1. Hide the password in the model
By default, the token holds the user data as JSON (toJson()), and anyone can read a JWT.
So the password hash must never be in toJson().
Put it in hidden.
For a token that holds only the user id, see Small tokens.
import 'package:dox_orm/dox_orm.dart';
part 'user.model.g.dart';
@DoxModel(table: 'users')
class User extends UserGenerator {
@override
List<String> get hidden => <String>['password'];
@Column()
String? name;
@Column()
String? email;
@Column()
String? password;
}
2. Set up the auth provider
import 'package:dox_auth/dox_auth.dart';
import 'package:dox_core/dox_core.dart';
import 'package:my_app/app/models/user/user.model.dart';
import 'package:my_app/config/env.dart';
class AppAuthProvider extends DoxProvider {
@override
void setup() {
Auth.initialize(
AuthConfig(
defaultGuard: 'web',
guards: <String, AuthGuard>{
'web': AuthGuard(
driver: JwtAuthDriver(
secret: SecretKey(env.appKey),
expiresIn: const Duration(days: 7),
),
provider: AuthProvider(model: () => User()),
),
},
),
);
}
}
Dox finds the provider by itself. Run dart run build_runner build and it is in lib/generated/container.g.dart.
3. Add the routes
AuthMiddleware protects a route.
When the token is missing or not valid, the request stops with 401 Authentication failed.
Put these routes in register() of the ApiRouter. Its prefix is api, so the paths start with /api.
A rate limit on login and register slows down password guessing.
Route.post('/auth/register', <dynamic>[
RateLimit(max: 5, name: 'register'),
(AuthController c) => c.register,
]);
Route.post('/auth/login', <dynamic>[
RateLimit(max: 5, name: 'login'),
(AuthController c) => c.login,
]);
Route.get('/auth/user', <dynamic>[AuthMiddleware(), (AuthController c) => c.user]);
4. Write the controller
import 'package:dox_auth/dox_auth.dart';
import 'package:dox_core/dox_core.dart';
import 'package:my_app/app/models/user/user.model.dart';
@Inject()
class AuthController {
/// Make an account and log in.
Future<dynamic> register(RegisterUser body) async {
User user = User();
user.name = body.name;
user.email = body.email;
user.password = await Hash.makeAsync(body.password);
await user.save();
String? token = Auth().login(user);
return <String, dynamic>{'token': token, 'user': user};
}
/// Check the email and password. Give a token.
Future<dynamic> login(LoginUser body) async {
Auth auth = Auth();
String? token = await auth.attempt(<String, dynamic>{
'email': body.email,
'password': body.password,
});
if (token == null) {
throw UnAuthorizedException(message: 'Wrong email or password');
}
return <String, dynamic>{'token': token, 'user': auth.user<User>()};
}
/// The logged in user.
User? user(DoxRequest req) {
return req.auth?.user<User>();
}
}
RegisterUser and LoginUser are @Dto classes. See Validation.
5. Send the token from the client
The Bearer prefix can be in any case, so bearer <token> also works.
JwtAuthDriver.bearerToken(req) gives the token of a request.
Read the user
After AuthMiddleware, req.auth holds the login.
| Code | What it gives |
|---|---|
req.auth |
an IAuth?. null when the route has no AuthMiddleware. It is kept apart from the input, so a query or body field can not set it |
req.auth?.isLoggedIn() |
true when a user is loaded |
req.auth == null |
true for a guest |
req.auth?.id() |
the primary key of the user, or null. It works when the key is in hidden |
req.auth?.user<User>() |
the user model |
req.auth?.toJson() |
the user as a map, without hidden fields |
The Auth class
| Method | What it does |
|---|---|
Auth.initialize(AuthConfig) |
sets the config. Call it once, in a provider |
Auth(guard: 'admin') |
uses another guard from guards. Without guard, it uses defaultGuard |
await Auth().attempt(credentials) |
finds the user and checks the password. Gives a token, or null |
Auth().login(user) |
gives a token for this user, with no password check |
Auth().setUser(req, user) |
sets this user on req.auth, with no token and no password check. For your own auth middleware |
Auth(ip: req.ip()) |
gives the client ip to attempt, for the login throttle |
await Auth().revokeToken(req) |
revokes the bearer token of this request (a JWT logout). Needs revocation |
await Auth().revokeAllTokens(id) |
revokes all tokens of this user made before the current second. A token made in the same second still works. Needs revocation |
await Auth().forgetUser(id) |
drops this user from the userCacheTtl cache. Needs revocation |
Auth.needsRehash(hash) |
true when the hash was not made with the cost of Hash.rounds. See Password hash cost |
Auth.hashCost(hash) |
the bcrypt cost of a hash, or null when it is not a bcrypt hash |
user<T>() |
the user after attempt or login |
await Auth().once(credentials) |
checks the password like attempt. Gives true or false. It makes no token and does not touch the session. The user is logged in for this Auth only |
isLoggedIn() |
true when there is a user |
check() |
the same as isLoggedIn() |
guest() |
true when there is no user |
id() |
the primary key of the user, or null for a guest |
attempt searches only by the fields in identifierFields. It runs bcrypt also when no user is found, or when the user has no password, so the answer time does not tell whether an email has an account.
It returns null when the password is not a String, when no identifier is given, when an identifier is not a string or a number (a map or a list from the body), or when the password does not match.
Options
AuthProvider
| Option | Default | What it does |
|---|---|---|
model |
required | a function that makes a new model, like () => User() |
identifierFields |
['email'] |
the fields attempt searches by |
passwordField |
'password' |
the column with the bcrypt hash |
JwtAuthDriver
| Option | Default | What it does |
|---|---|---|
secret |
required | the key that signs tokens, like SecretKey(env.appKey). An empty key throws ArgumentError. A key shorter than 32 bytes also throws in production, and prints a warning in development, local, test and testing. Make one with dox key:generate. For a separate JWT secret (not APP_KEY), openssl rand -hex 32 also works. Dox does not sign with this text as it is: the HMAC key is made from it with HKDF-SHA256 and the label dox:jwt. Another service that checks the tokens needs that key: driver.signingKey |
algorithm |
JWTAlgorithm.HS256 |
the signing algorithm. A token with another algorithm in its header is rejected |
expiresIn |
Duration(hours: 1) |
how long a token is valid |
issuer |
null |
the iss claim. When set, verifyToken also checks it |
hashRounds |
10 |
the bcrypt cost of your stored hashes. Use the same value as Hash.rounds |
userCacheTtl |
null (off) |
keep the user of a token in memory for this time. See below |
previousSecrets |
null |
old secrets, only to check tokens after a key change. null with secret = APP_KEY: the old keys come from AppConfig.previousKeys. [] turns it off. See below |
minimalPayload |
false |
put only the user id (the sub claim) in new tokens. See Small tokens |
revocation |
false |
turn on token revocation. See Revoke tokens |
revocationStore |
null |
the cache store for revoked tokens. null is the default app store |
Change the JWT secret
New tokens are always signed with secret.
A token is checked with secret first, then with each old secret.
Only a wrong signature moves on to the next secret. An expired token fails at once.
- The secret is
APP_KEY(SecretKey(env.appKey)): follow Change the key. The old keys inAppConfig.previousKeysare used. You need nothing more. -
A separate JWT secret: give the old one to the driver.
Remove the old secret once expiresIn has passed. Then every old token has expired anyway.
Do not keep a leaked secret: anyone who has it can make tokens.
Small tokens
By default, a token holds the user (toJson(), hidden fields left out), plus the iat, exp and jti claims.
Anyone can read a JWT payload.
The safer choice is a small token:
The token then holds only the user id (the sub claim).
Load the user with req.auth?.user<User>(), not from the token.
A front end that reads user fields from the token must then get the user from the API.
Revoke tokens
By default, a token works until it expires. There is no server list of tokens. Turn on revocation to stop a token before it expires:
Each token has a random id (the jti claim).
With revocation on, the JWT driver keeps revoked tokens in the app cache (Cache() of dox_core).
A revoked token stops working at once:
await Auth().revokeToken(req); // revoke the bearer token of this request (a JWT logout)
await Auth().revokeAllTokens(id); // revoke all tokens of a user, eg. after a password change or a ban
await Auth().forgetUser(id); // drop the user from `userCacheTtl`, eg. after a ban
- With revocation off, these three methods throw a
StateError. So no one thinks a token is revoked when it is not. - With revocation off,
verifyTokennever reads the cache. Auth().logout(req)is the session logout. For a JWT, userevokeToken.- A revoked token stays on the deny-list only until it expires.
revokeAllTokenskeeps tokens made after the call. A token made in the same second as the call also still works.- A token with no
jticlaim can not go on the deny-list, sorevokeTokendoes nothing for it.revokeAllTokensstill stops it when it has aniatclaim. Tokens fromJwtAuthDriverhave one. - The
revokeAllTokenstime comes from the server clock. Keep server clocks in sync (NTP). - Give auth its own store, and never flush it. For example, a
redisstore with its own prefix. A flush of this store (Cache().flush()), or amemorystore withmax_sizethat drops old keys, brings revoked tokens back until they expire. - Cost:
verifyTokenreads this store on every request, also withuserCacheTtl. It is onemany()call: one round trip onredisandmemory. On thefilestore (the default) it is 2 or 3 file reads per request. Usememoryfor one isolate, orredisfor many isolates or servers. - When the store can not be read, the token is checked with only the signature and
exp, and a warning is printed once. So a cache outage does not log everyone out. But revoked tokens work until the store is back.
Access tokens
Access tokens are API tokens that you can revoke at any time. They are like AdonisJS access tokens or Laravel Sanctum tokens. They are opt-in. Session and JWT guards do not change.
A token looks like oat_12.<secret>.
The table keeps only a SHA-256 hash of the secret.
You see the plain token once, when you make it.
A token works until it expires or you revoke it.
1. Add the guard
'api': AuthGuard(
driver: AccessTokenAuthDriver(), // table: 'auth_access_tokens', expiresIn: null (no end)
provider: AuthProvider(model: () => User()),
),
AccessTokenAuthDriver options:
| Option | Default | What it does |
|---|---|---|
table |
'auth_access_tokens' |
The table of the tokens. |
connection |
the default connection | The dox_orm connection name. |
expiresIn |
null (no end) |
How long a new token works. |
hashRounds |
10 |
The bcrypt cost, for attempt. |
type |
the guard name | The type of the tokens. A token works only on a guard with the same type. |
stringIds |
false |
true for users with text ids. Text ids are compared exactly. Use the same value in the migration. For uuid ids, see uuidIds. |
uuidIds |
false |
true for users with uuid ids. A uuid is kept and compared in lower case. Use the same value in the migration. |
2. Add the table
Run dox auth:tokens-table. It writes a migration in db/migration that makes the auth_access_tokens table. Then run dox migrate.
auth:tokens-table is a package command of dox_auth. It does not overwrite: when a create_access_tokens_table migration is already there, it prints so and stops.
Flags:
--uuid: for users with uuid ids (uuidIds: true).--string-ids: for users with text ids (stringIds: true).--users-table=users: adds a foreign key to this table (usersTable:).--users-key=uid: the key column of the users table, when it is notid(usersKey:).
For other options, like table:, edit the migration file. The migration it writes looks like this. You can also write it by hand. AccessTokenTables makes the table.
// ignore_for_file: file_names
import 'package:dox_auth/dox_auth.dart';
import 'package:dox_migration/dox_migration.dart';
/// The auth_access_tokens table of dox_auth.
class CreateAccessTokensTable extends Migration {
@override
Future<void> up(Schema schema) async {
await AccessTokenTables.up(schema);
}
@override
Future<void> down(Schema schema) async {
await AccessTokenTables.down(schema);
}
}
AccessTokenTables.up options:
| Option | Default | What it does |
|---|---|---|
table |
'auth_access_tokens' |
The table name. Use the same name as the driver table. |
stringIds |
false |
false: tokenable_id is an unsigned big int, like Sanctum and AdonisJS. true: it is text, for users with text ids. Use the same value as the driver stringIds. For uuid ids, see uuidIds. |
uuidIds |
false |
true: tokenable_id is a uuid column (UUID on Postgres), for users with uuid ids. Then usersTable works with a uuid id. Set uuidIds: true on the driver too. |
usersTable |
none | Adds a foreign key from tokenable_id to usersKey of this table, with cascade delete. Then the tokens of a deleted user are deleted too. The two id columns must have the same type. |
usersKey |
'id' |
The key column of usersTable, for the foreign key. |
With usersTable, use one token table per user model. If you have more than one user model (like users and admins), give each model its own token table, with table: on the driver and in the migration. With one shared table, the foreign key fails for admin tokens, and deleting a user also deletes the tokens of the admin with the same id.
The migration makes the table on the default connection.
If the driver uses another connection, like AccessTokenAuthDriver(connection: 'other'), make the table on that connection.
The table has these columns: id, tokenable_type, tokenable_id, type, name, hash, abilities, last_used_at, expires_at, created_at, updated_at.
3. Make and revoke tokens
// log in with a password: a new token with all abilities, or null
String? token = await Auth(guard: 'api').attempt(credentials);
// or make a token for a user
AccessToken token = await Auth(guard: 'api').createAccessToken(
user,
name: 'cli', // at most 255 characters
abilities: <String>['posts:read'], // default: ['*'] (all)
expiresIn: const Duration(days: 30), // default: the driver expiresIn
);
return <String, dynamic>{'token': token.value}; // shown only once
await Auth(guard: 'api').accessTokens(user); // all tokens of the user, newest first
await Auth(guard: 'api').revokeAccessToken(user, id); // false when the user has no such token
await Auth(guard: 'api').revokeAllAccessTokens(user); // eg. after a password change
await Auth(guard: 'api').revokeToken(req); // logout: revoke the token of this request
await Auth(guard: 'api').pruneExpiredAccessTokens(); // delete expired tokens, eg. from a scheduled job
In a route behind AuthMiddleware, (req.auth as Auth).revokeToken(req) uses the token that was already checked. So it does not check the token again.
accessTokens gives AccessToken objects. token.toJson() has no hash and no plain token, so you can send it to the client.
4. Protect routes and check abilities
The client sends Authorization: Bearer oat_12.<secret>.
Protect the route with AuthMiddleware(guard: 'api').
Then check the abilities of the token:
Auth auth = req.auth as Auth;
if (!auth.tokenCan('posts:write')) {
throw ForbiddenHttpException();
}
auth.currentAccessToken; // the AccessToken of this request
- A token fails (401) when its format is bad, the secret is wrong, it is expired, it was revoked, its user is gone, it has another type, or it was made for a guard with another user table.
tokenable_typeis the table name of the user model. If you rename the users table, the old tokens stop working.- A revoked token is deleted from the table, so it stops working at once.
- A database error also fails the check (401). The error goes to
AppConfig.exceptionHandler. - Each token has a
type. By default it is the guard name. So two access token guards do not take each other's tokens. If you rename a guard, setAccessTokenAuthDriver(type: 'old-name')to keep its tokens working. - A token
nameover 255 characters throws anArgumentError. Nothing is written. - Pass a user of the guard's model. For admins, use
Auth(guard: 'admin').createAccessToken(admin). A user of another model throws anArgumentError. - On Postgres a uuid is stored in lower case. With
uuidIds, the driver keeps and compares uuids in lower case too. On other databases, keep uuids in lower case in the users table. - When you delete a user, call
revokeAllAccessTokens(user)too, or useusersTablein the migration. Then old tokens can never match a new user with the same id. tokenCanisfalsewhen there is no access token. So a session or JWT login never passes it.last_used_atis written at most once a minute per token. So a busy token does not write on every request. If this write fails, the error is reported and the request goes on.Auth().login(user)throws aStateErrorwith this driver. UsecreateAccessToken.- Expired tokens stay in the table until you call
pruneExpiredAccessTokens(). - To check a token without a request, like in Socket.IO, use
Auth(guard: 'api').userFromToken(token). See Check a login with no request. The driver method behind it ischeck(token, guard), an instance method ofAccessTokenAuthDriver.
Login throttle
The login throttle limits failed logins per account and ip. It is off by default.
Turn it on in register() or boot() of a provider.
Auth.throttle is static, so each isolate has its own copy. If you set it in main() before the server starts, only the first isolate throttles.
Auth.throttle = LoginThrottle(maxAttempts: 5, decay: Duration(minutes: 1));
// JWT: pass the client ip
String? token = await Auth(ip: req.ip()).attempt(credentials);
// session: the ip comes from the request
bool ok = await Auth().attemptSession(req, credentials);
- After
maxAttemptsfailed logins for the same login field (eg. the email) and ip, the next try throwsLoginThrottledException(429) untildecayhas passed. ItsretryAftersays how long to wait. - A good login clears the count. The login field is trimmed and lower case, so
A@x.comanda@x.comshare one count. - The counts live in the app cache. Pick a store with
LoginThrottle(store: 'redis'). Use a shared store likeredisfor many isolates or servers. - Without an ip,
attemptcounts per account only. Then anyone can lock an account fordecay. So pass the ip. attemptSessionsends aRetry-Afterheader with the 429.attempthas no request, so catchLoginThrottledExceptionand set the header fromretryAfterif you need it.- A burst of parallel tries can get a few more than
maxAttempts, because the check and the count are separate cache steps.
Password hash cost
Passwords use bcrypt with cost 10 by default. Each step up doubles the time of a login. To raise it, set the cost for new hashes and tell the driver. Then a login for an unknown email takes as long as a wrong password:
Hash.rounds = 12;
JwtAuthDriver(secret: SecretKey(env.appKey), hashRounds: 12);
SessionAuthDriver(hashRounds: 12);
Old hashes keep their own cost, so they still work. Hash them again on the next good login:
Auth.needsRehash(hash, rounds: 12) checks against another cost.
More than one guard
Each guard has its own driver and its own user model. For example, users and admins in two tables:
AuthConfig(
defaultGuard: 'web',
guards: <String, AuthGuard>{
'web': AuthGuard(
driver: JwtAuthDriver(secret: SecretKey(env.appKey)),
provider: AuthProvider(model: () => User()),
),
'admin': AuthGuard(
driver: JwtAuthDriver(secret: SecretKey(env.appKey)),
provider: AuthProvider(model: () => Admin()),
),
},
);
Pick the guard by name:
String? token = await Auth(guard: 'admin').attempt(credentials);
Route.get('/admin', <dynamic>[AuthMiddleware(guard: 'admin'), (AdminController c) => c.index]);
The token keeps the guard name in its aud claim.
So a token from the web guard does not work on the admin guard, even with the same secret.
An unknown guard name throws an ArgumentError.
Custom guard (API key)
You can check the user in your own way, like an API key in the header.
Write a driver: extend AuthDriver and return the user from verifyToken.
AuthMiddleware and req.auth then work as with the other drivers.
The table stores a SHA-256 hash of each key in an api_key_hash column, not the key itself.
So a leak of the table does not leak the keys.
The example uses the crypto package (dart pub add crypto).
import 'dart:convert';
import 'package:crypto/crypto.dart';
import 'package:dox_auth/dox_auth.dart';
import 'package:dox_core/dox_core.dart';
import 'package:dox_orm/dox_orm.dart';
/// `Authorization: Bearer <api key>` gives the user with this key.
class ApiKeyAuthDriver extends AuthDriver {
@override
Future<Model<dynamic>?> verifyToken(IDoxRequest req, String guard) async {
String header = req.header('Authorization') ?? '';
if (!header.startsWith('Bearer ')) return null;
String key = header.substring(7).trim();
String hash = sha256.convert(utf8.encode(key)).toString();
Model<dynamic> model = AuthEngine().guard(guard).provider.model();
return await model.where('api_key_hash', hash).getFirst();
}
/// no password login for this guard
@override
Future<Model<dynamic>?> attempt(Map<String, dynamic> credentials, String guard) async => null;
/// no token to make: the key is the token
@override
String? createToken(Map<String, dynamic>? userPayload, String guard) => null;
}
Add it as a guard, and use the guard in the route:
'api': AuthGuard(
driver: ApiKeyAuthDriver(),
provider: AuthProvider(model: () => User()),
),
Route.get('/me', <dynamic>[AuthMiddleware(guard: 'api'), (DoxRequest req) => req.auth?.user<User>()]);
Make each key long and random, like openssl rand -hex 32. Then a fast hash like SHA-256 is safe. Do not use bcrypt here: you can not search by a bcrypt hash.
If you already have your own middleware, set the user with Auth().setUser(req, user).
It needs no token and no password check. req.auth then gives this user:
User? user = await keys.userForKey(key);
if (user == null) throw UnAuthorizedException();
Auth(guard: 'api').setUser(req, user);
return req;
Session login for web pages
For web pages, keep the login in the session instead of a token.
The browser sends the session cookie, so the client does nothing.
It needs SessionMiddleware and CsrfMiddleware. See Session.
Use SessionAuthDriver in the guard:
Log in and log out with the request:
Future<dynamic> login(DoxRequest req, LoginUser body) async {
bool ok = await Auth().attemptSession(req, <String, dynamic>{
'email': body.email,
'password': body.password,
});
if (!ok) {
req.session.flashErrors(<String, dynamic>{'email': 'Wrong email or password'});
return back(req);
}
return redirect('/dashboard');
}
DoxResponse logout(DoxRequest req) {
Auth().logout(req);
return redirect('/');
}
Send guests to the login page instead of a 401:
Route.get('/dashboard', <dynamic>[AuthMiddleware(redirectTo: '/login'), (PageController c) => c.dashboard]);
A public page can still know the user. AuthMiddleware(optional: true) sets req.auth when the login is good, and lets guests through too.
A guest, or a bad or expired token, goes on with req.auth as null.
With the JWT and access-token drivers, a database error also goes on as a guest, and it is reported.
With the session driver, a database error is not caught, so the answer is 500.
redirectTo is ignored when optional: true.
Login and register pages are for guests only. GuestMiddleware lets only guests through.
A logged-in user goes to redirectTo. With no redirectTo, the answer is 403 with the code already_logged_in.
Route.get('/posts', <dynamic>[AuthMiddleware(optional: true), (PostController c) => c.index]);
Route.get('/login', <dynamic>[GuestMiddleware(redirectTo: '/dashboard'), (PageController c) => c.login]);
Both take guard:, like AuthMiddleware(guard: 'admin').
AuthMiddleware also takes guards:, a list of guard names, like Laravel auth:web,api. It tries the guards in order. The first guard with a good login sets req.auth.
Route.get('/me', <dynamic>[AuthMiddleware(guards: <String>['web', 'api']), (UserController c) => c.me]);
Set guard: or guards:, not both. Both, or an empty list, throws an ArgumentError.
| Method | What it does |
|---|---|
await Auth().attemptSession(req, credentials) |
checks the password like attempt, and keeps the user id in the session. Gives true or false |
Auth().loginSession(req, user) |
keeps this user in the session, with no password check |
Auth().logout(req) |
removes the login and all session data. For a JWT, use revokeToken |
- Login and logout give the session a new id and a new CSRF token. So an id that someone saw before the login is of no use.
- With the default
cookiesession driver, all session data sits in the cookie. So an old copy of the cookie still works after logout, until the sessionlifetimeends. If logout must really end old cookies, use thecachesession driver, likeSessionConfig(driver: 'cache', store: 'redis'). - The session keeps only the user id.
AuthMiddlewareloads the user from the database on each request, so a deleted user is logged out. - These methods throw a
StateErroron a guard that does not useSessionAuthDriver. SessionAuthDriverhas one option,hashRounds(default10), likeJwtAuthDriver.
Check a login with no request
Code that has no request, like a Socket.IO login, can check a token or a session itself:
User? user = await Auth(guard: 'api').userFromToken(token);
User? user = await Auth(guard: 'web').userFromSession(session);
userFromTokenruns the same checks as a request with aBearerheader. For a JWT: signature, algorithm, guard,expand revocation. For an access token: the hash, the type, the user table,expires_atand revocation. Give the token with noBearerprefix. A missing, bad, expired, revoked or changed token givesnull. It needs a guard withJwtAuthDriverorAccessTokenAuthDriver.- With an access token,
currentAccessTokenandtokenCanwork on the sameAuthafteruserFromToken. userCacheTtlis a cache, not a check. With it on, a deleted or banned user can still connect until the cache time ends, unlessforgetUserruns.userFromSessiongives the user of a session, ornull. Load the session withSessionMiddleware().fromCookie(value). See Session. It needs a guard withSessionAuthDriver.- Both throw a
StateErroron a guard with the wrong driver.
Things to know
- The token is not secret. By default, anyone with the token can read
user.toJson(). Keep private columns inhidden, or use small tokens. - By default, you can not cancel one token. A token works until it expires. Turn on revocation to cancel one token, or all tokens of a user. Without revocation, to end all tokens, change the
secret(yourAPP_KEY) with no old secrets. See Change the JWT secret. Or use access tokens: they live in a table, and you can revoke them at any time. - The user is loaded on each request, by the id in the token. A deleted user can not log in, even with a valid token.
userCacheTtlskips that query. WithDuration(seconds: 30), a deleted or changed user is seen only after 30 seconds. Each isolate has its own cache. Requests in that time share one user object, so do not change it per request.- Login does not block the server.
attemptruns bcrypt in another isolate. - Hash passwords before you save them. See Hashing.