<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Lukasz Tarczyluk</title>
    <description>The latest articles on DEV Community by Lukasz Tarczyluk (@lukasz_tarczyluk).</description>
    <link>https://dev.to/lukasz_tarczyluk</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4034943%2F9a9488a9-93dc-4f83-a165-bd368cb4504d.png</url>
      <title>DEV Community: Lukasz Tarczyluk</title>
      <link>https://dev.to/lukasz_tarczyluk</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/lukasz_tarczyluk"/>
    <language>en</language>
    <item>
      <title>Getting Out of the Password Business</title>
      <dc:creator>Lukasz Tarczyluk</dc:creator>
      <pubDate>Sat, 18 Jul 2026 09:46:04 +0000</pubDate>
      <link>https://dev.to/lukasz_tarczyluk/getting-out-of-the-password-business-6f5</link>
      <guid>https://dev.to/lukasz_tarczyluk/getting-out-of-the-password-business-6f5</guid>
      <description>&lt;p&gt;Our old authentication system logged users in with the OAuth2 &lt;code&gt;client_credentials&lt;/code&gt; grant.&lt;/p&gt;

&lt;p&gt;If you know OAuth, you just winced. &lt;code&gt;client_credentials&lt;/code&gt; exists so that a client application can obtain a token &lt;em&gt;for itself&lt;/em&gt;, rather than on behalf of a user. It has no concept of a human being at all. But it takes two values, an ID and a secret, and if you squint, an email address and a password are also two values.&lt;/p&gt;

&lt;p&gt;So that's what it did. Each user effectively became an OAuth client: &lt;code&gt;client_id&lt;/code&gt; was their email address, and the registered client secret was their BCrypt password hash. The browser sent an HTTP Basic header, the server matched the credentials against that per-user registered client, and out came a token.&lt;/p&gt;

&lt;p&gt;It worked for years. That is the recurring theme of this post.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we actually had
&lt;/h2&gt;

&lt;p&gt;The platform is a multi-tenant billing system that ingests orders and payments from e-commerce marketplaces and produces accounting documents. Authentication was entirely in-house: a Spring Authorization Server configuration of about 380 lines, issuing and signing its own tokens. Access tokens lived 24 hours, refresh tokens 48.&lt;/p&gt;

&lt;p&gt;Issued tokens went into an &lt;code&gt;oauth2_authorization&lt;/code&gt; table, with a startup job that deleted rows older than a couple of days. Other services didn't parse tokens at all — they called back to the auth service to introspect each one as an opaque string. The oldest component in the system sat on the hot path of every authenticated request in every other service.&lt;/p&gt;

&lt;p&gt;Password hashes lived in a &lt;code&gt;password&lt;/code&gt; column on the user table, and registration hashed into it directly.&lt;/p&gt;

&lt;p&gt;None of this was incompetent. It was written when the platform was a single application, and it kept working while the platform stopped being one. The failure mode of home-grown auth is rarely a dramatic breach; it's that the thing quietly becomes load-bearing infrastructure that nobody signed up to own.&lt;/p&gt;

&lt;p&gt;Three forces finally moved it: security standards set by our parent company across the group, enterprise customers asking to sign in with their existing Google Workspace and Microsoft Entra ID accounts, and the accumulated cost of maintaining a bespoke identity stack.&lt;/p&gt;

&lt;h2&gt;
  
  
  The cut-over
&lt;/h2&gt;

&lt;p&gt;We moved to Google Identity Platform, primarily because our infrastructure already runs on Google Cloud — identity became native rather than another integration, with one tenant per environment.&lt;/p&gt;

&lt;p&gt;Verification ended up taking two forms, which I'd call a mild inconsistency rather than a mistake. The auth service verifies ID tokens through the Firebase Admin SDK in a servlet filter. Every other service uses the stock Spring resource server pointed at the provider's JWKS endpoint, so token validation there is ordinary configuration rather than code we maintain. Domain-specific facts — the selected organisation, the user's internal identifier, whether the caller is a service account — ride along as custom claims minted per token.&lt;/p&gt;

&lt;p&gt;Now the part I'd flag to anyone planning this: &lt;strong&gt;we cut over in a single day.&lt;/strong&gt; Five backend repositories and the frontend all merged on the same date. There was no dual-auth period, no compatibility endpoint that let old tokens be exchanged for new ones, and no feature flag to route a percentage of traffic. The frontend switched by pointing its login route at a new screen and leaving the old one unrouted.&lt;/p&gt;

&lt;p&gt;That was deliberate, and I'd defend it. The obvious alternative is a transition period where every service accepts both the old tokens and the new ones. But that doubles the authentication surface you have to reason about and test, and interim compatibility layers have a habit of outliving their purpose — six months later you still have two ways to authenticate and nobody wants to be the one who deletes the old path. With a single team and a fixed set of clients we controlled, one coordinated release with everybody present and watching struck me as the lower risk.&lt;/p&gt;

&lt;p&gt;The commit history immediately afterwards shows what that costs, honestly: a week of small fires — configuration corrections, test fixtures to repair, a handful of things that only surface once real traffic arrives. That's the trade. You concentrate the pain into a window where the whole team is paying attention, instead of spreading it across a quarter where they aren't.&lt;/p&gt;

&lt;p&gt;The most instructive failure arrived within the hour, and it wasn't in any of the code we'd just rewritten.&lt;/p&gt;

&lt;h2&gt;
  
  
  Five thousand accounts, zero password resets
&lt;/h2&gt;

&lt;p&gt;One constraint was non-negotiable: nobody gets a "please reset your password" email. That email, sent without a breach behind it, spends trust you can't easily earn back.&lt;/p&gt;

&lt;p&gt;The provider supports importing users along with their existing hashes, if you tell it the hash algorithm and parameters. So the migration exporter read the existing users, imported them in batches of a thousand with BCrypt declared as the hash scheme, and wrote the resulting provider identifiers back onto our own user rows.&lt;/p&gt;

&lt;p&gt;The hashes moved across intact. Everyone's existing password kept working the next morning, verified by the provider instead of by us. Nobody had to do anything and, which was the whole point, users simply carried on.&lt;/p&gt;

&lt;p&gt;That's the real end state. We are not in the business of storing credentials anymore. Our application does not verify a password, does not compare a hash, and would have nothing useful to leak on that front if the database were compromised tomorrow.&lt;/p&gt;

&lt;p&gt;What surprised me was how much arrived with it. Multi-factor authentication was the obvious one: TOTP, the familiar QR-code flow used by Google Authenticator and its equivalents. Enrolment, verification, recovery codes and the enforcement rules around them are weeks of careful work to build and a permanent obligation to maintain. We had never built it, and — being honest about our priorities — probably never would have. It came as configuration, as did federated sign-in for the identity providers our customers actually use. And the same foundation is what makes the passwordless work we're doing now — passkeys — tractable at all.&lt;/p&gt;

&lt;p&gt;That's the underrated part of delegating identity. It isn't only that you stop carrying a liability. It's that a whole category of security work stops being a project you have to justify and becomes a feature you switch on.&lt;/p&gt;

&lt;h2&gt;
  
  
  The account that wasn't there
&lt;/h2&gt;

&lt;p&gt;Scheduled jobs — billing runs, marketplace imports — authenticate as a technical user rather than as a person. Under the old system that was a real row in the database with a token persisted alongside it, updated by hand when it expired. Under the new one it's a service-account identity that mints and exchanges its own short-lived token.&lt;/p&gt;

&lt;p&gt;On release night, every machine-to-machine call started returning &lt;code&gt;401&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;The cause was not in the code. The technical user simply did not exist in the production database. It existed in development, where we'd built and tested everything, and had never been created in production. Human logins worked fine, because humans were covered by the account import. The one identity that wasn't a real person had fallen outside it.&lt;/p&gt;

&lt;p&gt;We caught it the same evening and fixed it, because the entire team was watching a release we knew was high-stakes. That is the argument for concentrating a migration into one window: the failure surfaced while everyone was still at their desks, rather than at 3am on a Tuesday three weeks later when nobody was.&lt;/p&gt;

&lt;p&gt;The lesson I took isn't about tokens at all. When you migrate identity, you naturally think about &lt;em&gt;users&lt;/em&gt; — the people with passwords, the ones who will notice and complain. Service accounts have none of those properties. Nobody advocates for them, they don't file support tickets, and they fall outside the mental model you're using when you plan the work. They're also the accounts that run everything important while you're asleep.&lt;/p&gt;

&lt;p&gt;Environment parity gets discussed as a configuration problem — same versions, same flags, same infrastructure. This was parity of &lt;em&gt;data&lt;/em&gt;: an identity that existed in one environment and not the other, in a system whose entire job is deciding who exists.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we quietly lost
&lt;/h2&gt;

&lt;p&gt;Migrations remove things, and it's worth being honest about which.&lt;/p&gt;

&lt;p&gt;The old system locked an account after three failed logins and emailed the user when a login arrived from an unusual location. Both were custom, both had been written by someone who cared, and both went away with the code that contained them. The provider brings its own protections against credential stuffing, so the net security position improved — but those specific behaviours weren't reimplemented, and nobody made an explicit decision to drop them. They simply weren't in scope, which is how features die.&lt;/p&gt;

&lt;p&gt;We also left ourselves some scaffolding. Migration code tends to arrive with a comment promising it will be deleted afterwards, and "afterwards" is not a date. Cleanup work that has no deadline doesn't get one, and the only reliable fix I know is to put the removal in the plan as a task with an owner, before the migration ships and everyone's attention moves on.&lt;/p&gt;

&lt;p&gt;The passkey work mentioned above is in progress — written and working on a branch, not yet merged.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Home-grown authentication doesn't fail loudly, it just accumulates.&lt;/strong&gt; Ours cost us SSO deals and put a decade-old component on the hot path of every request. The bill arrives in instalments.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A big-bang cut-over is a legitimate choice, not a failure of nerve.&lt;/strong&gt; Dual-authentication periods double the surface you have to test and tend to become permanent. Concentrating the risk into one window, with the whole team watching, can be the safer option — provided you actually watch.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Service accounts fall through the gaps in an identity migration.&lt;/strong&gt; You plan around users, because users are the ones who notice. The accounts that run your overnight work have nobody to speak for them and are absent from every checklist you write.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Environment parity includes data, not just configuration.&lt;/strong&gt; Same versions and same flags mean nothing if an identity exists in one environment and not the other.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Write down what you're dropping.&lt;/strong&gt; Account lockout and location alerts were real features that disappeared without anyone explicitly deciding they should. Removal by omission is the easiest kind to miss.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Delegating identity buys more than it removes.&lt;/strong&gt; Losing the credential liability was the goal; getting MFA and federated sign-in as configuration, and a solid foundation to build passwordless on, turned out to be worth more.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;For most applications, storing credentials has become an optional responsibility rather than a necessity.&lt;/strong&gt; It's still the right call for some. It just isn't the default any more, and it's worth asking whether you're holding it on purpose.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The best outcome isn't the SSO integration customers asked for. It's that "how do you store passwords?" now has a very short answer: we don't.&lt;/p&gt;

</description>
      <category>security</category>
      <category>authentication</category>
      <category>java</category>
      <category>architecture</category>
    </item>
    <item>
      <title>When You Inherit Software That Can No Longer Be Built</title>
      <dc:creator>Lukasz Tarczyluk</dc:creator>
      <pubDate>Sat, 18 Jul 2026 08:26:26 +0000</pubDate>
      <link>https://dev.to/lukasz_tarczyluk/when-you-inherit-software-that-can-no-longer-be-built-1nka</link>
      <guid>https://dev.to/lukasz_tarczyluk/when-you-inherit-software-that-can-no-longer-be-built-1nka</guid>
      <description>&lt;p&gt;There is a particular kind of silence that falls over a room when you ask a simple question and nobody can answer it.&lt;/p&gt;

&lt;p&gt;The question was: &lt;em&gt;how do we build this?&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;The system in front of us was a set of pricing applications at a large European bank — the components that answer "what is this instrument worth today" for the front office. They consumed live market data from Reuters feeds, applied the bank's own valuation logic, and served the result to booking and settlement systems downstream. They had been running for over a decade and, by every operational measure, were working fine — restarted once a week, otherwise left alone.&lt;/p&gt;

&lt;p&gt;And nobody could build them.&lt;/p&gt;

&lt;p&gt;The developers who had written them had left the organisation years earlier. What remained was a deployed artefact — one image, built at some point in the past by someone who understood how all the pieces fitted together, running unchanged ever since. The source code existed. The build did not. No one still employed knew how to assemble a deployable package from that source together with its full set of dependent technologies.&lt;/p&gt;

&lt;p&gt;If you have never encountered this, it sounds like negligence. It isn't. It's entropy. It happens slowly, to well-run organisations, and by the time anyone notices, the people who could have explained it are three jobs away.&lt;/p&gt;

&lt;h2&gt;
  
  
  What "unbuildable" actually costs
&lt;/h2&gt;

&lt;p&gt;A frozen system that works is easy to rationalise. It works. Leave it alone.&lt;/p&gt;

&lt;p&gt;The cost is invisible until you need something.&lt;/p&gt;

&lt;p&gt;A defect appears. You cannot fix it — you can only work around it in the systems downstream. A dependency turns out to have a published vulnerability. You cannot upgrade it; you compensate at the network layer and write a risk acceptance instead. A regulation changes, and this is finance, so regulations change. You cannot implement the change where it belongs.&lt;/p&gt;

&lt;p&gt;And underneath all of it the runtime itself is ageing out. The Java version eventually loses support, and with it your ability to run on infrastructure anyone is willing to maintain.&lt;/p&gt;

&lt;p&gt;Every one of these turns into a workaround somewhere else, and each workaround makes the next change harder. The frozen component quietly exports its rigidity to everything around it.&lt;/p&gt;

&lt;p&gt;So the first deliverable of the project was not a feature. It was the recovery of the ability to change the software at all.&lt;/p&gt;

&lt;h2&gt;
  
  
  The archaeology phase
&lt;/h2&gt;

&lt;p&gt;Before proposing a target, we had to understand what was actually there.&lt;/p&gt;

&lt;p&gt;Archaeology is the right word, because there was nothing else to go on. No internal documentation had been left behind — no architecture notes, no runbook, nothing explaining why any of it was shaped the way it was.&lt;/p&gt;

&lt;p&gt;External help was thin too. Oracle's OCEP was never widely adopted; the vendor documentation is sparse and the community around it is small enough that searching an error message often returns nothing at all.&lt;/p&gt;

&lt;p&gt;The product did ship a visual editor that rendered the event-processing flows — the one artefact that would have explained the transformations at a glance. It ran in a browser, and it required Flash, which had been end-of-life for years. On a bank-issued machine you install nothing that isn't on the security-approved software list, and an obsolete browser plugin was never going to be on it. The tool that would have explained the system existed, and was unreachable.&lt;/p&gt;

&lt;p&gt;So I read the XML instead. Line by line, file by file, reconstructing what each transformation did and how the stages fed one another, until the shape of the system existed somewhere other than inside a program nobody could run anymore.&lt;/p&gt;

&lt;p&gt;That is the unglamorous centre of most legacy work. Not clever refactoring — patiently rebuilding an understanding that was never written down in the first place.&lt;/p&gt;

&lt;p&gt;Two things dominated the architecture that emerged from this:&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;OSGi, inside the applications.&lt;/strong&gt; Each pricing application was built as an OSGi bundle system — decomposed into modules with dynamic loading, versioned interfaces, and a service registry.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A separate event-processing server, outside them.&lt;/strong&gt; For event handling, the applications published events to a dedicated Oracle Complex Event Processing (OCEP) server that ran alongside them, with its rules and streams configured outside the application code.&lt;/p&gt;

&lt;p&gt;Both are legitimate technologies, chosen by capable people for real reasons. In this system, though, both had become expensive — in operational complexity, in the amount of context a developer must hold before making a safe change, and in the number of ways a deployment can fail.&lt;/p&gt;

&lt;p&gt;The question I kept asking was not "how do we migrate this" but "&lt;strong&gt;what requirement made this necessary?&lt;/strong&gt;"&lt;/p&gt;

&lt;p&gt;For OSGi: the promise is hot-swapping modules at runtime without restarting the process, and running multiple versions of a dependency side by side. Did these applications need that? They went down on a schedule every week regardless, and nobody had hot-deployed a bundle in years. The dynamic module model was carrying no weight.&lt;/p&gt;

&lt;p&gt;For OCEP: the promise is high-throughput stream processing with complex temporal rule matching over continuous event streams. What was it actually doing? Routing a modest volume of events between the applications — work that belongs in a few lines of in-process code, not in a dedicated server standing alongside them.&lt;/p&gt;

&lt;p&gt;This is the uncomfortable finding that a legacy migration often surfaces: &lt;strong&gt;the complexity is not load-bearing.&lt;/strong&gt; It was a reasonable bet by capable engineers under different assumptions, and those assumptions stopped being true years ago. Nobody re-examined it, because the system worked, and because re-examining it required exactly the build capability that had been lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  Choosing boring on purpose
&lt;/h2&gt;

&lt;p&gt;The target architecture was deliberately unambitious: rebuild each application as an ordinary Spring Boot service on a current Java LTS — dropping the OSGi bundle model inside it, and replacing the external OCEP server it published to with plain in-process publish/subscribe (Guava's &lt;code&gt;EventBus&lt;/code&gt;, at the time), which covered the entire real requirement in a fraction of the surface area.&lt;/p&gt;

&lt;p&gt;This is the part that tends to attract argument, because the industry reflex runs the other way — towards shared platforms and extracted infrastructure. But nothing here was being shared to any purpose: both pieces were an entire extra tier to run, secure, patch and understand, in exchange for capability the applications did not use. Taking it away and letting each stand on its own removed cost, not capability.&lt;/p&gt;

&lt;p&gt;The decisive criterion, though, was the one we started with: &lt;strong&gt;any Java developer must be able to build and release this.&lt;/strong&gt; That single requirement eliminated every option that preserved the old packaging. A conventional Maven build producing a conventional Spring Boot artefact is not an interesting technical choice. It is an interesting &lt;em&gt;organisational&lt;/em&gt; one, because it means the system's future no longer depends on knowledge held by specific people.&lt;/p&gt;

&lt;p&gt;Boring technology is legible technology. Legible technology survives staff turnover.&lt;/p&gt;

&lt;h2&gt;
  
  
  The constraint that shaped everything: don't move the contract
&lt;/h2&gt;

&lt;p&gt;While this was underway, the bank went through a corporate integration and several internal upstream systems were replaced with the acquirer's equivalents. The market data feed itself was unaffected — what changed was the systems sitting behind it. New connectors were needed.&lt;/p&gt;

&lt;p&gt;And here was the hard constraint: a substantial number of legacy systems consumed these applications, some against older versions, and &lt;strong&gt;they had to keep receiving byte-for-byte identical responses&lt;/strong&gt;. We were changing what was behind the interface. We were not permitted to change the interface — not the shape, not the field ordering, not the numeric formatting, not the rounding.&lt;/p&gt;

&lt;p&gt;That last one matters more than it sounds. Financial output is full of details that a rewrite quietly "improves": a trailing zero, a scale on a decimal, a null rendered as an empty string. Every one of those is a production incident in a downstream system you have never heard of.&lt;/p&gt;

&lt;p&gt;The approach was straightforward but disciplined:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Map the new sources back onto the &lt;strong&gt;existing&lt;/strong&gt; response model rather than designing a better one.&lt;/li&gt;
&lt;li&gt;Capture real production responses from the running system as a reference corpus.&lt;/li&gt;
&lt;li&gt;Verify equivalence of the new implementation against that corpus before cut-over — not "looks right", but identical output for identical input.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;Recorded production traffic is the only honest specification a legacy system has. The documentation describes intent. The code describes mechanism. Only the observed output describes what downstream consumers actually depend on — including the behaviours nobody meant to promise.&lt;/p&gt;

&lt;h2&gt;
  
  
  Closing the loop
&lt;/h2&gt;

&lt;p&gt;The last piece was the one that made the rest durable: a CI/CD pipeline that builds from source, runs the tests, and promotes through environments onto a container platform. Ordinary, in other words — which was the whole point.&lt;/p&gt;

&lt;p&gt;The before-and-after is the clearest measure of the project.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Before:&lt;/strong&gt; an artefact nobody could rebuild, left untouched for years, with every change request answered by a workaround somewhere else.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;After:&lt;/strong&gt; any Java developer on the team can build and release a new version in minutes.&lt;/p&gt;

&lt;p&gt;No latency numbers, no throughput graphs. The system priced instruments the same way on the last day as on the first. What changed is that it became &lt;em&gt;possible to change it&lt;/em&gt; — and that turned out to be the capability the organisation had actually lost.&lt;/p&gt;

&lt;h2&gt;
  
  
  What I'd take from it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;"It works" is not the same as "it's fine."&lt;/strong&gt; A system you cannot rebuild is a system with an expiry date; you just don't know the date.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Ask what requirement justified the complexity.&lt;/strong&gt; Often the requirement is gone and only the complexity remains. Sophisticated technology chosen for reasons that no longer apply is just cost.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Optimise for who can operate it, not for elegance.&lt;/strong&gt; If only one person can build it, you don't have software — you have a dependency on that person.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Recorded output is the real contract.&lt;/strong&gt; Not the docs, not the schema. Behaviour that consumers depend on is behaviour you promised, whether you meant to or not.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Restoring changeability is legitimate work.&lt;/strong&gt; It doesn't demo well and it has no user-visible feature attached, but nothing else you want to do is possible until it's done.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The most valuable thing we delivered was not a rewrite. It was ending the silence that follows &lt;em&gt;how do we build this?&lt;/em&gt;&lt;/p&gt;

</description>
      <category>java</category>
      <category>architecture</category>
      <category>legacycode</category>
      <category>devops</category>
    </item>
  </channel>
</rss>
