Bahadır Bilir

identity-service

OpenID Connect provider. Device-bound sessions, TOTP second factor, sign-in through external providers, account deletion under KVKK and GDPR.

Issues the tokens and keeps the sessions the other services rely on.

  • A session is bound to the device it was opened on; a revoked session keeps the time and the reason.
  • Second factor with TOTP.
  • External sign-in through one adapter per provider; a provider is added after a sign-in against the real provider.
  • The user deletes the account; the other services delete their data on the identity.user.deleted event.
  • Integration tests run on Postgres and Redis; the load tests are below.
  • Deployed to AWS on demand from the infrastructure repository and removed after use.

Load tests

The infrastructure repository creates the AWS instances for a run: one machine with nginx, the service, PostgreSQL 18, Redis and Mailpit, and a load generator in the same subnet. Each step runs three times for 60 s; the latency charts show the median of the three p99s. Capacity is the highest step that stays under the limit without the server falling behind.

The current version on a c7a.large, 2 cores:

  • 100 password logins/s with p99 under 250 ms.
  • 1,200 token refreshes/s with p99 under 50 ms, nginx reaching the service over a Unix socket.
  • One login per 144 refreshes held at 1,200 requests/s: about 720,000 active users who refresh once every 600 s.

The first campaign compared 2 and 4 cores:

Password login: p99 latency against offered load
Show
  • c7a.large, 2 cores
  • c7a.xlarge, 4 cores
  • limit, 250 ms

p99, ms

logins per second

c7a.large, 2 cores
logins per secondp99, ms
1728.8
3529.8
4839.8
5552.6
6282.4
69115.4
76136.3
c7a.xlarge, 4 cores
logins per secondp99, ms
3423.4
6928.6
9633.3
11038.4
12345.5
13755.5
15168.4

Capacity 76 logins/s on 2 cores and 151 on 4: at the next step the server fell behind. Every login verifies an Argon2id hash, so login capacity grows with the cores.

Token refresh: p99 latency against offered load
Show
  • c7a.large, 2 cores
  • c7a.xlarge, 4 cores
  • limit, 50 ms

p99, ms

refreshes per second

c7a.large, 2 cores
refreshes per secondp99, ms
2376
47412.3
66320.8
75827
85233.2
94742.6
1,04249.9
1,18492.8
c7a.xlarge, 4 cores
refreshes per secondp99, ms
4634.3
9256.1
1,2958.9
1,48011.5
1,66515.7
1,85023.2

Capacity 1,042/s on 2 cores and 1,850/s on 4. On 2 cores the whole machine was 96 % busy at the ceiling: nginx, PostgreSQL and Redis share the cores with the one Node.js process.

Token refresh on c7a.large: TCP through a published port against a Unix socket
Show
  • TCP
  • Unix socket
  • limit, 50 ms

p99, ms

refreshes per second

p99, ms
refreshes per secondTCPUnix socket
30066.1
60012.913.3
84024.621.7
96029.626.7
1,0803732.1
1,20050.938.6
1,32072.458.9

Capacity 1,080/s over TCP and 1,200/s over the socket, 11 % more. docker-proxy, which copies every byte between nginx and a published port, used close to a tenth of a core at the ceiling; over the socket that work is gone.

Argon2id verifies per second, concurrency equal to the cores
Show
  • c7a.large, 2 cores
  • c7a.xlarge, 4 cores
  1. 46 MiB, 1 pass74/s124.1/s
  2. 19 MiB, 2 passes: before105.8/s186.1/s
  3. 12 MiB, 3 passes123.2/s211.6/s
  4. 9 MiB, 4 passes124.7/s225/s
  5. 7 MiB, 5 passes: chosen129.4/s238.2/s
c7a.large, 2 coresc7a.xlarge, 4 cores
46 MiB, 1 pass74/s124.1/s
19 MiB, 2 passes: before105.8/s186.1/s
12 MiB, 3 passes123.2/s211.6/s
9 MiB, 4 passes124.7/s225/s
7 MiB, 5 passes: chosen129.4/s238.2/s

Parameter sets OWASP lists as equivalent. 7 MiB with five passes verifies 22 % more per second than 19 MiB with two on 2 cores, and 28 % more on 4.

Past login capacity, the first version kept a queue of every login waiting for its hash. The current one bounds it: past capacity a login is refused at once with 503 and Retry-After, and a login still waiting after one second is refused instead of verified.

Logins past capacity on c7a.large: one minute at twice capacity
Show
  • Unbounded queue
  • Bounded queue
  • twice capacity

logins that succeeded, %

seconds from the start of the overload, by when a login was sent

logins that succeeded, %
seconds from the start of the overload, by when a login was sentUnbounded queueBounded queue
-15100100
-10100100
-5100100
010050.1
567.752.6
10054
15054.3
20055.7
25055.1
30055.5
35055.8
40055
45054.2
50055.7
55057.3
600100
650100
700100
750100
800100
850100
900100
950100
1000100
1050100
1100100
1150100
1200100
1250100
1300100
1350100
1400100
14515.3100
150100100
155100100
160100100
165100100
170100100
175100100

Without a bound no login succeeded from 10 s to 145 s: the service went on verifying passwords for clients that had already given up, also through the two minutes at half capacity that followed. With the bound it served about 93 logins/s through the overload, refused the rest at once, timed nobody out, and was back under the latency limit in the first five seconds after the overload.