One identity, many apps
The company runs several products, but a person has one account. One account app owns sign-in, sign-up and profile. Every product app sends users there and gets a token back. Read this before you add a new product surface or touch sign-in.
How it works
Each product has a public landing page that anyone can open, and private pages that need a token from the account app.
SSO (single sign-on) means one sign-in works across many apps. The account app is the SSO origin: the one place that signs people in.
Signing in from a product app
A token here is a JWT: a signed string that says who the user is and which kind of client may use it. That "kind" is the audience. We used three: app users, staff, and guests (people mid-onboarding who have not signed up).
Tokens travel in the Authorization: Bearer header, not in cookies.
Advice, not something we measured: keep long-lived tokens out of URLs. A redirect URL ends up in browser history and server logs. The standard pattern is to put a short-lived one-time code in the redirect and swap it for the token from the product app's server.
How to build it
Inside the core API
- Shared modules hold data that means the same in every product: the person, sign-in, payments, notifications.
- App-specific modules hold one product's data, in their own Postgres schema. They point at
user_idbut never change shared tables. - A shared module must not store data whose meaning depends on which app you are in. A saved item in product A belongs in product A's module, even though it has a
user_id.
For each new product surface
When you fork an existing web app repo to start a new one, rename these before the first deploy:
| File | Rename |
|---|---|
| Build spec | Container repository name, container name, config prefix |
| Deploy spec | Container name |
| Task definition per environment | Family, execution role, task role, container name, log group |
Keep the service name short. AWS limits a target group name (the list of
tasks a load balancer sends traffic to) to 32 characters. Our names were built as
company-env-service-tg, so the prefix and suffix used up most of that. Plan for
a service name under about 19 characters.
Rules
Never build sign-in, sign-up, profile or account settings in a product app. They live in the account app only. A second sign-in form means two places to fix every security bug.
One core API behind every surface. A new product does not get a new backend. It gets new modules inside the core API.
One user id per person, created once, by the account app's sign-up. Every app and every analytics event uses that same id.
Never share a container repository, task definition family or config prefix between two apps. If two apps share one, a deploy of one can replace the other.
Staff endpoints always check the staff audience. No flag or environment setting may widen that check.
Mistakes we made
- Forked repos almost deployed over a live app. Two new web apps were forked from an existing one. They still carried the original's container repository, container name, config prefix, task definition family and log group. The first deploy tag would have built into the original app's repository and replaced the live app. Instead: rename the five values in the table above before the first tag, and check them in review.
- An old hostname stayed attached to the wrong app. When the account app took over a hostname that used to serve a product, the old load balancer rule stayed in place, and one product hostname had no DNS record in Terraform at all. Instead: when a hostname changes owner, remove the old rule and record, and adopt any record that exists only in the console.
- A dev flag widened the staff gate. A setting that relaxed staff permission checks for development also let any token reach staff endpoints. Instead: the audience check is unconditional. Dev flags may relax role checks, never audience checks.
Checklist
- My product app has no sign-in form of its own.
- Private pages redirect to the account app and read the token back.
- My service has its own hostname, service, pipeline, container repository and config prefix.
- I renamed every value copied from the repo I forked.
- My service name is short enough for the target group name limit.