Keycloak SMTP Configuration and Authentik Email: 6 Steps
Keycloak SMTP configuration and authentik's email env block, field by field — the realm Email tab, the Email Stage, and the five errors that eat an evening.
Keycloak SMTP configuration and authentik's email env block, field by field — the realm Email tab, the Email Stage, and the five errors that eat an evening.
Our first real user on a fresh Keycloak realm got stuck behind a grey box reading You need to verify your email address to activate your account. No resend button. No way forward. Nothing in their inbox, because the realm's Keycloak SMTP configuration was blank — there was nothing to send it with.
I'd built that realm an hour earlier. Created the client, wired the frontend, tested the redirect, watched the token come back — and never opened the Email tab.
Here's the thing about self-hosted identity: SMTP isn't an optional extra for next week. It's load-bearing. Verify-email, forgot-password, admin invites, mail-based OTP — all of it dead-ends at an unconfigured relay, and both Keycloak and authentik fail the same way. Quietly. With the user holding the bag and no error they can see.
Authenticated outbound SMTP on both systems, sending from a subdomain you control, so these actually land:
keycloak@localhostTwo unrelated config surfaces. Keycloak SMTP configuration lives inside the realm — admin UI, per realm, in the database. authentik reads environment variables at boot, with optional per-stage overrides in the flow editor. Having configured one does not prepare you for the other.
One pre-flight check, from inside the Keycloak container:
timeout 5 bash -c 'cat < /dev/null > /dev/tcp/smtp.justemails.app/587' && echo open
Awkward, I know. The official Keycloak image ships no nc (no telnet either — it's a deliberately thin image, and on balance that's the right call), so bash's /dev/tcp is what you've got. If it hangs, your host filters outbound 587 and no config here will fix it. Which is a miserable thing to find at midnight, on a Friday, with settings you know are correct.
Create a single mailbox for identity mail. Something like login@id.yourdomain.com.
Note the subdomain. Not the root domain, not the mailbox you read.
Authentication mail is the most consequential mail you send — if a reset link lands in spam, your user is locked out, full stop. Keeping that stream on id.yourdomain.com means a bad week on your newsletter can't drag it down, and DMARC reports arrive split by stream instead of blended into one useless average. The dedicated sending subdomain walkthrough covers the DNS side. Mailboxes are unlimited on the $49/year plan, and that credential is about to sit in a plaintext .env — when you rotate it, exactly one machine should care.
Resist naming it noreply@. People reply to reset emails, usually when something has already gone wrong, and routing those into a void is a bad look on a login system. We've made this argument at length.
Keep four values handy: host smtp.justemails.app, port 587, username the full address, and the mailbox password.
Realm Settings → Email, and read that first word again. Realm settings. SMTP is per realm, not per install — configure it on master and you get working admin mail and nothing at all for your application realm, which keeps failing in the same silent way. I've watched two people lose an afternoon to this. One of them was me.
| Field | Value |
|---|---|
| From | login@id.yourdomain.com |
| From display name | Acme ID |
| Reply to | support@yourdomain.com |
| Envelope from | leave empty |
| Host | smtp.justemails.app |
| Port | 587 |
| Enable StartTLS | on |
| Enable SSL | off |
| Enable Authentication | on — username is the full address |
Envelope from stays empty. Keycloak then reuses the From address as the SMTP-level sender, which is what you want: SPF checks the envelope, DMARC checks that envelope and header domains line up, identical addresses pass both. Put a different domain in there and alignment breaks — mail still sends, DMARC fails, resets land in quarantine, nobody tells you. That last clause is the entire problem with email.
StartTLS on 587, SSL on 465, never both. Port 587 opens plaintext then upgrades; 465 is TLS from the first byte. Tick the wrong box and the handshake error reads like a certificate problem.
Keycloak SMTP configuration as code? The realm JSON block:
"smtpServer": {
"host": "smtp.justemails.app",
"port": "587",
"from": "login@id.yourdomain.com",
"fromDisplayName": "Acme ID",
"replyTo": "support@yourdomain.com",
"starttls": "true",
"ssl": "false",
"auth": "true",
"user": "login@id.yourdomain.com",
"password": "your-mailbox-password"
}
Those booleans are strings. "true", with quotes — real booleans get rejected on import, a fun twenty minutes when you're hand-writing a realm file.
And the trap worth tattooing somewhere: exporting a realm masks the SMTP password. The output carries "password": "**********", so re-importing that file gives you a realm that looks perfectly configured and cannot send anything. Keep the credential in your secret store and patch it in at deploy time:
/opt/keycloak/bin/kcadm.sh update realms/acme \
-s 'smtpServer.host=smtp.justemails.app' \
-s 'smtpServer.port=587' \
-s 'smtpServer.auth=true' \
-s 'smtpServer.starttls=true' \
-s 'smtpServer.user=login@id.yourdomain.com' \
-s "smtpServer.password=$SMTP_PASSWORD" \
-s 'smtpServer.from=login@id.yourdomain.com' \
-s 'smtpServer.fromDisplayName=Acme ID'
Set every key in one call — piecemeal updates to a nested map leave unmentioned keys in a state you didn't intend.
Finishing the Keycloak SMTP configuration enables nothing that actually sends mail. Separate tab, separate toggles, and this is the step people skip.
Realm Settings → Login:
Then the one almost nobody finds until it bites: Realm Settings → Tokens → user-initiated action lifespan. Default is five minutes.
Five minutes. For a password reset link.
Now put real mail in the loop. Greylisting is normal receiver behaviour — a first attempt from an unfamiliar sender gets a temporary deferral, and the retry lands one to fifteen minutes later. Your relay did nothing wrong, the message arrives fine, and the link inside it is already dead. User clicks, hits an expired-action page, requests another, same loop. Bump it to 30 minutes. (Sixty, if your users skew non-technical and check mail on a phone once an evening.) There's no meaningful security difference on a single-use token, and the five-minute default is simply wrong for anything public-facing.
authentik doesn't put SMTP in the UI. It reads environment variables, double underscore marking the nesting:
AUTHENTIK_EMAIL__HOST=smtp.justemails.app
AUTHENTIK_EMAIL__PORT=587
AUTHENTIK_EMAIL__USERNAME=login@id.yourdomain.com
AUTHENTIK_EMAIL__PASSWORD=your-mailbox-password
AUTHENTIK_EMAIL__USE_TLS=true
AUTHENTIK_EMAIL__USE_SSL=false
AUTHENTIK_EMAIL__TIMEOUT=10
AUTHENTIK_EMAIL__FROM=Acme ID [login@id.yourdomain.com](mailto:login@id.yourdomain.com)
People complain about this constantly — why isn't it in the admin UI? Honestly, I've come around to it. Credentials in environment variables are credentials your deployment tooling already knows how to rotate.
USE_TLS is STARTTLS on 587. USE_SSL is implicit TLS on 465. Setting both true doesn't degrade gracefully — authentik runs on Django, whose mail backend treats them as mutually exclusive and raises on startup. Pick one. TIMEOUT is seconds, and a value beats none: without it a wedged connection can hold a request thread while your login page spins.
The part that eats the most time: the worker sends the mail, not the server. Both services need these variables. The stock Compose file shares an env_file, so .env covers both — but split services or Helm with per-deployment env make it easy to configure only server, see a healthy instance, and lose every message. Restart both; values are read at boot.
I lost most of a Saturday to this one, certain the relay was at fault, drafting a support ticket I never sent. It was my Helm chart.
Then test from the worker, not the server:
docker compose run --rm worker test_email you@example.com
On Kubernetes:
kubectl exec -it deploy/authentik-worker -- ak test_email you@example.com
Global config gives authentik the ability to send. It doesn't put a single email into a single flow. That's the Email Stage, a component you place yourself.
Flows and Stages → Stages, create an Email Stage. What matters in there:
email/account_confirmation.html for enrollment, the recovery template for resets.Then bind it into your enrollment flow, where the user should stop and check their mail.
And the recovery side, where I see the most confusion: authentik ships a default recovery flow that isn't wired to anything. It exists, it contains an email stage, and it does nothing until you attach it — as the recovery flow on your brand, or on the Identification Stage in your authentication flow. Until then there's no Forgot password? link on the login screen at all. Not a broken one. Absent. Users assume the feature doesn't exist, and they're technically right.
Shipping a default flow that's bound to nothing is a defensible design choice. I still think it's the wrong one.
Keycloak has a Test connection button on the Email tab. It sends to the address on the admin account you are logged in as, so a bootstrap admin with no email set can't use it — set an email on your own admin user first. It also tests the values sitting in the form rather than what's saved, so a green test still needs a Save click after.
For authentik, test_email from the worker is the equivalent.
Once a message lands, open the raw headers:
Authentication-Results: ...
spf=pass
dkim=pass
dmarc=pass
All three should read pass. JustEmails signs DKIM and auto-configures the SPF, DKIM, DMARC and MTA-STS records, but receivers verify only what's live in DNS — so dkim=none on a domain you added twenty minutes ago is propagation, not a mistake. Give it an hour. Still failing tomorrow? The DMARC enforcement walkthrough covers what breaks and in what order.
jakarta.mail.AuthenticationFailedException: 535 in the Keycloak log — almost always the username. Use the full address, login@id.yourdomain.com, not login. Second suspect: a trailing space pasted in with the password.
Unrecognized SSL message, plaintext connection? — Enable SSL is on with port 587. Untick it, tick StartTLS.
Connection times out on 465 — the inverse. StartTLS on an implicit-TLS port, so the handshake never starts.
Verification links point at localhost:8080 — looks like a mail bug, isn't. Keycloak builds those URLs from its hostname config, so behind a reverse proxy you need KC_HOSTNAME=https://id.yourdomain.com and --proxy-headers=xforwarded. The mail is fine; the link inside it is unusable.
EMAIL_USE_TLS/EMAIL_USE_SSL are mutually exclusive in the authentik worker log — both set to true. See Step 4.
test_email works, real flows send nothing — the Email Stage isn't in the flow, or the recovery flow isn't bound. A passing test proves the relay works, not that any flow uses it.
Everything arrives, but in spam — check envelope alignment first, then whether the subdomain has been live less than a day. If both look fine and Gmail still buries it, resist the urge to start rewriting DNS at random; send real mail to real people for a week first, because reputation on a brand-new subdomain is earned, not configured.
The test sends to the email address on the admin account you're logged in as. If that account has no email set — the default for the bootstrap admin, created by KC_BOOTSTRAP_ADMIN_USERNAME on Keycloak 26 or KEYCLOAK_ADMIN on older builds — there's no recipient and the test can't run. Set an email on your own admin user, then retest. It also uses the values currently in the form rather than what's saved, so a passing test still needs a Save click.
No. Keycloak SMTP configuration lives in realm settings, so every realm carries its own copy. Configuring it on master gets you working admin mail and nothing else — your application realm will still fail silently. If you run more than two or three realms, script it with kcadm rather than clicking through the console, and set every smtpServer key in one command.
Mail is sent by the worker container, not the server. If the email environment variables only reached the server service, every real flow silently drops its message. Put them in a shared env_file both services read, restart both, then run test_email from the worker. The other common cause is USE_TLS and USE_SSL both set to true — the Django mail backend treats those as mutually exclusive and raises rather than falling back.
Yes, and it costs nothing on a flat plan with unlimited domains. A marketing complaint spike on your root domain shouldn't touch password-reset deliverability, and a subdomain gets its own SPF and DKIM records — so DMARC reports show you which stream has the problem instead of one blended average.
Unlimited custom domain email hosting for $49/year flat — unlimited domains, unlimited mailboxes, 10 GB storage, full IMAP/SMTP. Built for agencies, freelancers, and anyone managing email across more than one domain.